diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..a19e39d81 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.js] +indent_style = space +indent_size = 2 + +[*.php] +indent_style = space +indent_size = 4 + +[{package.json, *.yml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..9d23fb5a9 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 30 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..caff1b0c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,169 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + static_analysis: + name: Static analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: technote-space/get-diff-action@v6 + with: + PATTERNS: | + pkg/**/*.php + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + extensions: mongodb, redis, :xdebug + ini-values: memory_limit=2048M + + - run: php ./bin/fix-symfony-version.php "5.4.*" + + - uses: "ramsey/composer-install@v3" + + - run: sed -i 's/525568/16777471/' vendor/kwn/php-rdkafka-stubs/stubs/constants.php + + - run: cd docker && docker build --rm --force-rm --no-cache --pull --tag "enqueue/dev:latest" -f Dockerfile . + - run: docker run --workdir="/mqdev" -v "`pwd`:/mqdev" --rm enqueue/dev:latest php -d memory_limit=1024M bin/phpstan analyse -l 1 -c phpstan.neon --error-format=github -- ${{ env.GIT_DIFF_FILTERED }} + if: env.GIT_DIFF_FILTERED + + code_style_check: + name: Code style check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: technote-space/get-diff-action@v6 + with: + PATTERNS: | + pkg/**/*.php + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-cs-check-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-cs-check- + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + extensions: mongodb, redis, :xdebug + ini-values: memory_limit=2048M + + - run: php ./bin/fix-symfony-version.php "5.4.*" + + - run: composer update --no-progress + + - run: sed -i 's/525568/16777471/' vendor/kwn/php-rdkafka-stubs/stubs/constants.php + + - run: ./bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --no-interaction --dry-run --diff -v --path-mode=intersection -- ${{ env.GIT_DIFF_FILTERED }} + if: env.GIT_DIFF_FILTERED + + unit_tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2'] + symfony_version: ['6.2.*', '6.3.*', '6.4.*', '7.0.*'] + dependencies: ['--prefer-lowest', '--prefer-dist'] + exclude: + - php: '8.1' + symfony_version: '7.0.*' + + name: PHP ${{ matrix.php }} unit tests on Sf ${{ matrix.symfony_version }}, deps=${{ matrix.dependencies }} + + steps: + - uses: actions/checkout@v4 + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ matrix.php }}-${{ matrix.symfony_version }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ matrix.php }}-${{ matrix.symfony_version }}-${{ matrix.dependencies }}- + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mongodb, redis, :xdebug + ini-values: memory_limit=2048M + + - run: php ./bin/fix-symfony-version.php "${{ matrix.symfony_version }}" + + - run: composer update --no-progress ${{ matrix.dependencies }} + + - run: sed -i 's/525568/16777471/' vendor/kwn/php-rdkafka-stubs/stubs/constants.php + + - run: bin/phpunit --exclude-group=functional + + functional_tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.1', '8.2' ] + symfony_version: [ '6.4.*', '7.0.*', '7.1.*', '7.2.*' ] + dependencies: [ '--prefer-lowest', '--prefer-dist' ] + exclude: + - php: '8.1' + symfony_version: '7.0.*' + - php: '8.1' + symfony_version: '7.1.*' + - php: '8.1' + symfony_version: '7.2.*' + + name: PHP ${{ matrix.php }} functional tests on Sf ${{ matrix.symfony_version }}, deps=${{ matrix.dependencies }} + + steps: + - uses: actions/checkout@v4 + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ matrix.php }}-${{ matrix.symfony_version }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ matrix.php }}-${{ matrix.symfony_version }}-${{ matrix.dependencies }}- + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mongodb, redis, :xdebug + ini-values: memory_limit=2048M + + - run: php ./bin/fix-symfony-version.php "${{ matrix.symfony_version }}" + + - run: composer update --no-progress ${{ matrix.dependencies }} + + - run: sed -i 's/525568/16777471/' vendor/kwn/php-rdkafka-stubs/stubs/constants.php + + - run: bin/dev -b + env: + PHP_VERSION: ${{ matrix.php }} + + - run: bin/test.sh --group=functional diff --git a/.gitignore b/.gitignore index b9809c577..7a2e2ec9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,21 @@ *~ /.idea/ bin/doctrine* -bin/php-cs-fixer -bin/phpunit -bin/sql-formatter +bin/php-cs-fixer* +bin/phpunit* +bin/sql-formatter* +bin/phpstan* +bin/jp.php* +bin/php-parse* +bin/google-cloud-batch* +bin/patch-type-declarations* +bin/thruway +bin/var-dump-server* +bin/yaml-lint* vendor +var .php_cs -.php_cs.cache \ No newline at end of file +.php_cs.cache +composer.lock +.phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 000000000..b9316b59b --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,36 @@ +setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setRules(array( + '@Symfony' => true, + '@Symfony:risky' => true, + 'array_syntax' => array('syntax' => 'short'), + 'combine_consecutive_unsets' => true, + // one should use PHPUnit methods to set up expected exception instead of annotations + 'general_phpdoc_annotation_remove' => ['annotations' => + ['expectedException', 'expectedExceptionMessage', 'expectedExceptionMessageRegExp'] + ], + 'heredoc_to_nowdoc' => true, + 'no_extra_blank_lines' => ['tokens' => [ + 'break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block'] + ], + 'no_unreachable_default_argument_value' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'psr_autoloading' => true, + 'strict_param' => true, + 'native_function_invocation' => false, + )) + ->setCacheFile(getenv('TRAVIS') ? getenv('HOME') . '/php-cs-fixer/.php-cs-fixer' : __DIR__.'/var/.php_cs.cache') + ->setFinder( + PhpCsFixer\Finder::create() + ->name('/\.php$/') + ->in(__DIR__) + ) +; diff --git a/.php_cs.dist b/.php_cs.dist deleted file mode 100644 index 99a418499..000000000 --- a/.php_cs.dist +++ /dev/null @@ -1,28 +0,0 @@ -setRiskyAllowed(true) - ->setRules(array( - '@Symfony' => true, - '@Symfony:risky' => true, - 'array_syntax' => array('syntax' => 'short'), - 'combine_consecutive_unsets' => true, - // one should use PHPUnit methods to set up expected exception instead of annotations - 'general_phpdoc_annotation_remove' => array('expectedException', 'expectedExceptionMessage', 'expectedExceptionMessageRegExp'), - 'heredoc_to_nowdoc' => true, - 'no_extra_consecutive_blank_lines' => array('break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block'), - 'no_unreachable_default_argument_value' => true, - 'no_useless_else' => true, - 'no_useless_return' => true, - 'ordered_class_elements' => true, - 'ordered_imports' => true, - 'phpdoc_add_missing_param_annotation' => true, - 'phpdoc_order' => true, - 'psr4' => true, - 'strict_param' => true, - )) - ->setFinder( - PhpCsFixer\Finder::create() - ->in(__DIR__) - ) -; \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 511703b9d..000000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -sudo: required - -git: - depth: 10 - -language: php - -php: - - '5.6' - - '7.0' - -services: - - docker - -cache: - directories: - - $HOME/.composer/cache - -install: - - sudo /etc/init.d/mysql stop - - rm $HOME/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini - - echo "memory_limit=2048M" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini - - pkg/amqp-ext/travis/build-php-amqp-ext - - cd $TRAVIS_BUILD_DIR - - composer self-update - - composer update --prefer-source - -script: - - bin/phpunit --exclude-group=functional - - bin/dev -bt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..fe4ddabbd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1460 @@ +# Change Log + +## [0.10.26](https://github.com/php-enqueue/enqueue-dev/tree/0.10.26) (2025-05-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.25...0.10.26) + +**Merged pull requests:** + +- Fix: Updating composer [\#1383](https://github.com/php-enqueue/enqueue-dev/pull/1383) ([JimTools](https://github.com/JimTools)) +- Fix: Fixing CI [\#1382](https://github.com/php-enqueue/enqueue-dev/pull/1382) ([JimTools](https://github.com/JimTools)) + +## [0.10.25](https://github.com/php-enqueue/enqueue-dev/tree/0.10.25) (2025-04-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.24...0.10.25) + +**Merged pull requests:** + +- Bugfix/static drift [\#1373](https://github.com/php-enqueue/enqueue-dev/pull/1373) ([JimTools](https://github.com/JimTools)) +- CS Fixes [\#1372](https://github.com/php-enqueue/enqueue-dev/pull/1372) ([JimTools](https://github.com/JimTools)) +- Fixing risky tests [\#1371](https://github.com/php-enqueue/enqueue-dev/pull/1371) ([JimTools](https://github.com/JimTools)) + +## [0.10.24](https://github.com/php-enqueue/enqueue-dev/tree/0.10.24) (2024-11-30) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.23...0.10.24) + +**Merged pull requests:** + +- SF7 deprecations fix [\#1364](https://github.com/php-enqueue/enqueue-dev/pull/1364) ([zavitkov](https://github.com/zavitkov)) +- add symfony 7 support for enqueue-bundle [\#1362](https://github.com/php-enqueue/enqueue-dev/pull/1362) ([zavitkov](https://github.com/zavitkov)) + +## [0.10.23](https://github.com/php-enqueue/enqueue-dev/tree/0.10.23) (2024-10-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.22...0.10.23) + +**Merged pull requests:** + +- Drop useless call to end method [\#1359](https://github.com/php-enqueue/enqueue-dev/pull/1359) ([ddziaduch](https://github.com/ddziaduch)) + +## [0.10.22](https://github.com/php-enqueue/enqueue-dev/tree/0.10.22) (2024-08-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.21...0.10.22) + +**Merged pull requests:** + +- GPS: revert the attributes and use the headers instead. [\#1355](https://github.com/php-enqueue/enqueue-dev/pull/1355) ([p-pichet](https://github.com/p-pichet)) + +## [0.10.21](https://github.com/php-enqueue/enqueue-dev/tree/0.10.21) (2024-08-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.20...0.10.21) + +**Merged pull requests:** + +- feat\(GPS\): allow send attributes in Google PubSub message. [\#1349](https://github.com/php-enqueue/enqueue-dev/pull/1349) ([p-pichet](https://github.com/p-pichet)) + +## [0.10.19](https://github.com/php-enqueue/enqueue-dev/tree/0.10.19) (2023-07-15) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.18...0.10.19) + +**Merged pull requests:** + +- fix: do not reset attemps header when message is requeue [\#1301](https://github.com/php-enqueue/enqueue-dev/pull/1301) ([eortiz-tracktik](https://github.com/eortiz-tracktik)) +- Allow doctrine/persistence 3.1 version [\#1300](https://github.com/php-enqueue/enqueue-dev/pull/1300) ([xNarkon](https://github.com/xNarkon)) +- Add support for rediss and phpredis [\#1297](https://github.com/php-enqueue/enqueue-dev/pull/1297) ([splagemann](https://github.com/splagemann)) +- Replaced `json\_array` with `json` due to Doctrine Dbal 3.0 [\#1294](https://github.com/php-enqueue/enqueue-dev/pull/1294) ([NovakHonza](https://github.com/NovakHonza)) +- pkg PHP 8.1 and 8.2 support [\#1292](https://github.com/php-enqueue/enqueue-dev/pull/1292) ([snapshotpl](https://github.com/snapshotpl)) +- Update doctrine/persistence [\#1290](https://github.com/php-enqueue/enqueue-dev/pull/1290) ([jlabedo](https://github.com/jlabedo)) +- Add PHP 8.1 and 8.2, Symfony 6.2 to CI [\#1285](https://github.com/php-enqueue/enqueue-dev/pull/1285) ([andrewmy](https://github.com/andrewmy)) +- \[SNSQS\] added possibility to send FIFO-related parameters using snsqs transport [\#1278](https://github.com/php-enqueue/enqueue-dev/pull/1278) ([onatskyy](https://github.com/onatskyy)) + +## [0.10.18](https://github.com/php-enqueue/enqueue-dev/tree/0.10.18) (2023-03-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.17...0.10.18) + +**Merged pull requests:** + +- Fix Shield URLs in READMEs [\#1289](https://github.com/php-enqueue/enqueue-dev/pull/1289) ([amayer5125](https://github.com/amayer5125)) +- Fix AWS SDK token parameter [\#1284](https://github.com/php-enqueue/enqueue-dev/pull/1284) ([andrewmy](https://github.com/andrewmy)) +- MongoDB - Add combined index [\#1283](https://github.com/php-enqueue/enqueue-dev/pull/1283) ([ddziaduch](https://github.com/ddziaduch)) +- Add setting subscription attributes to Sns and SnsQs [\#1281](https://github.com/php-enqueue/enqueue-dev/pull/1281) ([andrewmy](https://github.com/andrewmy)) +- code style fix \(native\_constant\_invocation\) [\#1276](https://github.com/php-enqueue/enqueue-dev/pull/1276) ([EmilMassey](https://github.com/EmilMassey)) +- \[amqp-lib\] Replace amqp-lib deprecated public property with getters [\#1273](https://github.com/php-enqueue/enqueue-dev/pull/1273) ([ramunasd](https://github.com/ramunasd)) +- fix: parenthesis missing allowed invalid delays [\#1266](https://github.com/php-enqueue/enqueue-dev/pull/1266) ([aldenw](https://github.com/aldenw)) +- Allow rdkafka falsy keys [\#1264](https://github.com/php-enqueue/enqueue-dev/pull/1264) ([qkdreyer](https://github.com/qkdreyer)) +- Symfony config allow null [\#1263](https://github.com/php-enqueue/enqueue-dev/pull/1263) ([h0raz](https://github.com/h0raz)) +- Ensure pass consumer tag as string to bunny amqp [\#1255](https://github.com/php-enqueue/enqueue-dev/pull/1255) ([snapshotpl](https://github.com/snapshotpl)) +- chore: Update dependency dbal [\#1253](https://github.com/php-enqueue/enqueue-dev/pull/1253) ([meidlinga](https://github.com/meidlinga)) + +## [0.10.17](https://github.com/php-enqueue/enqueue-dev/tree/0.10.17) (2022-05-17) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.16...0.10.17) + +**Merged pull requests:** + +- Disable sleep while queue items available [\#1250](https://github.com/php-enqueue/enqueue-dev/pull/1250) ([mordilion](https://github.com/mordilion)) + +## [0.10.16](https://github.com/php-enqueue/enqueue-dev/tree/0.10.16) (2022-04-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.15...0.10.16) + +**Merged pull requests:** + +- Upgrade ext-rdkafka to 6.0 [\#1241](https://github.com/php-enqueue/enqueue-dev/pull/1241) ([lucasrivoiro](https://github.com/lucasrivoiro)) +- Replace rabbitmq-management-api with a packagist source and fixed small github actions typo [\#1240](https://github.com/php-enqueue/enqueue-dev/pull/1240) ([oreillysean](https://github.com/oreillysean)) +- Add support for Symfony 6; drop \< 5.1 [\#1239](https://github.com/php-enqueue/enqueue-dev/pull/1239) ([andrewmy](https://github.com/andrewmy)) +- Replace rabbitmq-management-api with a packagist source [\#1238](https://github.com/php-enqueue/enqueue-dev/pull/1238) ([andrewmy](https://github.com/andrewmy)) +- Fix CI [\#1237](https://github.com/php-enqueue/enqueue-dev/pull/1237) ([jdecool](https://github.com/jdecool)) +- Allow ext-rdkafka 6 usage [\#1233](https://github.com/php-enqueue/enqueue-dev/pull/1233) ([jdecool](https://github.com/jdecool)) +- Fix types for Symfony 5.4 [\#1225](https://github.com/php-enqueue/enqueue-dev/pull/1225) ([shyim](https://github.com/shyim)) + +## [0.10.15](https://github.com/php-enqueue/enqueue-dev/tree/0.10.15) (2021-12-11) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.14...0.10.15) + +**Merged pull requests:** + +- feat\(snsqs\): allow client http configuration for sns and sqs [\#1216](https://github.com/php-enqueue/enqueue-dev/pull/1216) ([eortiz-tracktik](https://github.com/eortiz-tracktik)) +- Add FIFO logic to SNS [\#1214](https://github.com/php-enqueue/enqueue-dev/pull/1214) ([kate-simozhenko](https://github.com/kate-simozhenko)) +- Fix falling tests [\#1211](https://github.com/php-enqueue/enqueue-dev/pull/1211) ([snapshotpl](https://github.com/snapshotpl)) +- RdKafka; Replace composer-modifying for testing with --ignore-platform-req argument [\#1210](https://github.com/php-enqueue/enqueue-dev/pull/1210) ([maartenderie](https://github.com/maartenderie)) +- Allow psr/container v2 [\#1206](https://github.com/php-enqueue/enqueue-dev/pull/1206) ([ADmad](https://github.com/ADmad)) + +## [0.10.14](https://github.com/php-enqueue/enqueue-dev/tree/0.10.14) (2021-10-29) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.13...0.10.14) + +**Merged pull requests:** + +- Fix passed parameters for compatibility with newest version of dbal [\#1203](https://github.com/php-enqueue/enqueue-dev/pull/1203) ([dgafka](https://github.com/dgafka)) +- Allow psr/log v2 and v3 [\#1198](https://github.com/php-enqueue/enqueue-dev/pull/1198) ([snapshotpl](https://github.com/snapshotpl)) +- Fix partition's choice for the cases when partition number is zero [\#1196](https://github.com/php-enqueue/enqueue-dev/pull/1196) ([rodrigosarmentopicpay](https://github.com/rodrigosarmentopicpay)) +- Added getter for offset field in RdKafkaConsumer class [\#1184](https://github.com/php-enqueue/enqueue-dev/pull/1184) ([DigitVE](https://github.com/DigitVE)) + +## [0.10.13](https://github.com/php-enqueue/enqueue-dev/tree/0.10.13) (2021-08-25) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.12...0.10.13) + +**Merged pull requests:** + +- \[SNSQS\] added possibility to send message attributes using snsqs transport [\#1195](https://github.com/php-enqueue/enqueue-dev/pull/1195) ([onatskyy](https://github.com/onatskyy)) +- Add in missing arg [\#1194](https://github.com/php-enqueue/enqueue-dev/pull/1194) ([gdsmith](https://github.com/gdsmith)) +- \#1190 add index on delivery\_id to prevent slow queries [\#1191](https://github.com/php-enqueue/enqueue-dev/pull/1191) ([commercewerft](https://github.com/commercewerft)) +- Add setTopicArn methods to SnsContext and SnsQsContext [\#1189](https://github.com/php-enqueue/enqueue-dev/pull/1189) ([gdsmith](https://github.com/gdsmith)) + +## [0.10.11](https://github.com/php-enqueue/enqueue-dev/tree/0.10.11) (2021-04-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.10...0.10.11) + +**Merged pull requests:** + +- Perform at least once delivery when rejecting with requeue [\#1165](https://github.com/php-enqueue/enqueue-dev/pull/1165) ([dgafka](https://github.com/dgafka)) +- Fix dbal delivery delay to always keep integer value [\#1161](https://github.com/php-enqueue/enqueue-dev/pull/1161) ([dgafka](https://github.com/dgafka)) +- Add SqsConsumer methods to SnsQsConsumer [\#1160](https://github.com/php-enqueue/enqueue-dev/pull/1160) ([gdsmith](https://github.com/gdsmith)) +- add subscription\_interval as config for dbal subscription consumer [\#1159](https://github.com/php-enqueue/enqueue-dev/pull/1159) ([mordilion](https://github.com/mordilion)) +- register worker callback only once, move to constructor [\#1157](https://github.com/php-enqueue/enqueue-dev/pull/1157) ([cturbelin](https://github.com/cturbelin)) +- Try to change doctrine/orm version for supporting 2.8 \(PHP 8 support\). [\#1155](https://github.com/php-enqueue/enqueue-dev/pull/1155) ([GothShoot](https://github.com/GothShoot)) +- sns context - fallback for not breaking BC with 10.10 previous versions [\#1149](https://github.com/php-enqueue/enqueue-dev/pull/1149) ([bafor](https://github.com/bafor)) + +## [0.10.10](https://github.com/php-enqueue/enqueue-dev/tree/0.10.10) (2021-03-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.9...0.10.10) + +**Merged pull requests:** + +- \[sns\] added possibility to define already existing topics \(prevent create topic call\) \#1022 [\#1147](https://github.com/php-enqueue/enqueue-dev/pull/1147) ([paramonov](https://github.com/paramonov)) +- \[gps\] Add support for consuming message from external publisher in non-standard format [\#1118](https://github.com/php-enqueue/enqueue-dev/pull/1118) ([maciejzgadzaj](https://github.com/maciejzgadzaj)) + +## [0.10.9](https://github.com/php-enqueue/enqueue-dev/tree/0.10.9) (2021-03-17) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.8...0.10.9) + +**Merged pull requests:** + +- Upgrade php-amqplib to v3.0 [\#1146](https://github.com/php-enqueue/enqueue-dev/pull/1146) ([masterjus](https://github.com/masterjus)) +- Split tests into different matrices; fix highest/lowest dependencies [\#1139](https://github.com/php-enqueue/enqueue-dev/pull/1139) ([andrewmy](https://github.com/andrewmy)) + +## [0.10.8](https://github.com/php-enqueue/enqueue-dev/tree/0.10.8) (2021-02-17) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.7...0.10.8) + +**Merged pull requests:** + +- Fix package CI [\#1138](https://github.com/php-enqueue/enqueue-dev/pull/1138) ([andrewmy](https://github.com/andrewmy)) +- add sns driver + use profile to establish connection [\#1134](https://github.com/php-enqueue/enqueue-dev/pull/1134) ([fbaudry](https://github.com/fbaudry)) +- Add PHP 8 [\#1132](https://github.com/php-enqueue/enqueue-dev/pull/1132) ([andrewmy](https://github.com/andrewmy)) + +## [0.10.7](https://github.com/php-enqueue/enqueue-dev/tree/0.10.7) (2021-02-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.6...0.10.7) + +**Merged pull requests:** + +- PHPUnit 9.5 [\#1131](https://github.com/php-enqueue/enqueue-dev/pull/1131) ([andrewmy](https://github.com/andrewmy)) +- Fix the build matrix [\#1130](https://github.com/php-enqueue/enqueue-dev/pull/1130) ([andrewmy](https://github.com/andrewmy)) +- Disable Travis CI [\#1129](https://github.com/php-enqueue/enqueue-dev/pull/1129) ([makasim](https://github.com/makasim)) +- Add GitHub Action CI [\#1127](https://github.com/php-enqueue/enqueue-dev/pull/1127) ([andrewmy](https://github.com/andrewmy)) +- Allow ext-rdkafka 5 [\#1126](https://github.com/php-enqueue/enqueue-dev/pull/1126) ([andrewmy](https://github.com/andrewmy)) +- Fix - Bad parameter for exception [\#1124](https://github.com/php-enqueue/enqueue-dev/pull/1124) ([atrauzzi](https://github.com/atrauzzi)) +- \[fix\] queue consumption: catch throwable for processing errors [\#1114](https://github.com/php-enqueue/enqueue-dev/pull/1114) ([macghriogair](https://github.com/macghriogair)) +- Ramsey dependency removed in favor to \Enqueue\Util\UUID::generate [\#1110](https://github.com/php-enqueue/enqueue-dev/pull/1110) ([inri13666](https://github.com/inri13666)) +- Added: ability to choose different entity manager [\#1081](https://github.com/php-enqueue/enqueue-dev/pull/1081) ([balabis](https://github.com/balabis)) + +## [0.10.6](https://github.com/php-enqueue/enqueue-dev/tree/0.10.6) (2020-10-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.5...0.10.6) + +**Merged pull requests:** + +- fixing issue \#1085 [\#1105](https://github.com/php-enqueue/enqueue-dev/pull/1105) ([nivpenso](https://github.com/nivpenso)) +- Fix DoctrineConnectionFactoryFactory due to doctrine/common changes [\#1089](https://github.com/php-enqueue/enqueue-dev/pull/1089) ([kdefives](https://github.com/kdefives)) + +## [0.10.5](https://github.com/php-enqueue/enqueue-dev/tree/0.10.5) (2020-10-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.4...0.10.5) + +**Merged pull requests:** + +- update image [\#1104](https://github.com/php-enqueue/enqueue-dev/pull/1104) ([nick-zh](https://github.com/nick-zh)) +- \[rdkafka\]use supported librdkafka version of ext [\#1103](https://github.com/php-enqueue/enqueue-dev/pull/1103) ([nick-zh](https://github.com/nick-zh)) +- \[rdkafka\] add non-blocking poll call to serve cb's [\#1102](https://github.com/php-enqueue/enqueue-dev/pull/1102) ([nick-zh](https://github.com/nick-zh)) +- \[rdkafka\] remove topic conf, deprecated [\#1101](https://github.com/php-enqueue/enqueue-dev/pull/1101) ([nick-zh](https://github.com/nick-zh)) +- \[stomp\] Fix - Add automatic reconnect support for STOMP producers [\#1099](https://github.com/php-enqueue/enqueue-dev/pull/1099) ([atrauzzi](https://github.com/atrauzzi)) +- fix localstack version \(one that worked\) [\#1094](https://github.com/php-enqueue/enqueue-dev/pull/1094) ([makasim](https://github.com/makasim)) +- Allow false-y values for unsupported options [\#1093](https://github.com/php-enqueue/enqueue-dev/pull/1093) ([atrauzzi](https://github.com/atrauzzi)) +- Lock doctrine perisistence version. Fix tests. [\#1092](https://github.com/php-enqueue/enqueue-dev/pull/1092) ([makasim](https://github.com/makasim)) + +## [0.10.4](https://github.com/php-enqueue/enqueue-dev/tree/0.10.4) (2020-09-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.3...0.10.4) + +**Merged pull requests:** + +- \[stomp\] Add first pass for Apache ActiveMQ Artemis support [\#1091](https://github.com/php-enqueue/enqueue-dev/pull/1091) ([atrauzzi](https://github.com/atrauzzi)) +- \[amqp\]Solves binding Headers Exchange with Queue using custom arguments [\#1087](https://github.com/php-enqueue/enqueue-dev/pull/1087) ([dgafka](https://github.com/dgafka)) +- \[async-command\] Fix service definition to apply the timeout [\#1084](https://github.com/php-enqueue/enqueue-dev/pull/1084) ([jcrombez](https://github.com/jcrombez)) +- \[mongodb\] fix\(MongoDB\) Redelivery not working \(fixes \#1077\) [\#1078](https://github.com/php-enqueue/enqueue-dev/pull/1078) ([josefsabl](https://github.com/josefsabl)) +- Add php 7.3 and 7.4 travis env to every package [\#1076](https://github.com/php-enqueue/enqueue-dev/pull/1076) ([snapshotpl](https://github.com/snapshotpl)) +- Docs: update Supported Brokers [\#1074](https://github.com/php-enqueue/enqueue-dev/pull/1074) ([Nebual](https://github.com/Nebual)) +- \[rdkafka\] Compatibility with Phprdkafka 4.0 [\#959](https://github.com/php-enqueue/enqueue-dev/pull/959) ([Steveb-p](https://github.com/Steveb-p)) + +## [0.10.3](https://github.com/php-enqueue/enqueue-dev/tree/0.10.3) (2020-07-31) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.2...0.10.3) + +**Merged pull requests:** + +- Allow to install ramsey/uuid:^4 [\#1075](https://github.com/php-enqueue/enqueue-dev/pull/1075) ([snapshotpl](https://github.com/snapshotpl)) +- chore: add typehint to RdKafkaConsumer\#getQueue [\#1071](https://github.com/php-enqueue/enqueue-dev/pull/1071) ([qkdreyer](https://github.com/qkdreyer)) +- Fixes typo on client messages exemples doc [\#1065](https://github.com/php-enqueue/enqueue-dev/pull/1065) ([brunousml](https://github.com/brunousml)) +- Fix contact us link [\#1058](https://github.com/php-enqueue/enqueue-dev/pull/1058) ([andrew-demb](https://github.com/andrew-demb)) +- Fix typos [\#1049](https://github.com/php-enqueue/enqueue-dev/pull/1049) ([pgrimaud](https://github.com/pgrimaud)) +- Added support for ramsey/uuid 4.0 [\#1043](https://github.com/php-enqueue/enqueue-dev/pull/1043) ([a-menshchikov](https://github.com/a-menshchikov)) +- Changed: cast redelivery\_delay to int [\#1034](https://github.com/php-enqueue/enqueue-dev/pull/1034) ([balabis](https://github.com/balabis)) +- Add php 7.4 to test matrix [\#991](https://github.com/php-enqueue/enqueue-dev/pull/991) ([snapshotpl](https://github.com/snapshotpl)) + +## [0.10.2](https://github.com/php-enqueue/enqueue-dev/tree/0.10.2) (2020-03-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.1...0.10.2) + +**Merged pull requests:** + +- Implement DeliveryDelay, Priority and TimeToLive in PheanstalkProducer [\#1033](https://github.com/php-enqueue/enqueue-dev/pull/1033) ([likeuntomurphy](https://github.com/likeuntomurphy)) +- fix\(mongodb\): Exception throwing fatal error, Broken handling of Mong… [\#1032](https://github.com/php-enqueue/enqueue-dev/pull/1032) ([josefsabl](https://github.com/josefsabl)) +- RUN\_COMMAND Option example [\#1030](https://github.com/php-enqueue/enqueue-dev/pull/1030) ([gam6itko](https://github.com/gam6itko)) +- typo [\#1026](https://github.com/php-enqueue/enqueue-dev/pull/1026) ([sebastianneubert](https://github.com/sebastianneubert)) +- Add extension tag parameter note [\#1023](https://github.com/php-enqueue/enqueue-dev/pull/1023) ([Steveb-p](https://github.com/Steveb-p)) +- STOMP. add additional configuration [\#1018](https://github.com/php-enqueue/enqueue-dev/pull/1018) ([versh23](https://github.com/versh23)) + +## [0.10.1](https://github.com/php-enqueue/enqueue-dev/tree/0.10.1) (2020-01-31) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.10.0...0.10.1) + +**Merged pull requests:** + +- \[dbal\] fix: allow absolute paths for sqlite transport [\#1015](https://github.com/php-enqueue/enqueue-dev/pull/1015) ([cawolf](https://github.com/cawolf)) +- \[tests\] Add schema declaration to phpunit files [\#1014](https://github.com/php-enqueue/enqueue-dev/pull/1014) ([Steveb-p](https://github.com/Steveb-p)) +- \[rdkafka\] Catch consume error "Local: Broker transport failure" and continue consume [\#1009](https://github.com/php-enqueue/enqueue-dev/pull/1009) ([rdotter](https://github.com/rdotter)) +- \[sqs\] SQS Transport - Add support for AWS profiles. [\#1008](https://github.com/php-enqueue/enqueue-dev/pull/1008) ([bgaillard](https://github.com/bgaillard)) +- \[amqp\] fixes \#1003 Return value of Enqueue\AmqpLib\AmqpContext::declareQueue() must be of the type int [\#1004](https://github.com/php-enqueue/enqueue-dev/pull/1004) ([kalyabin](https://github.com/kalyabin)) +- \[gearman\] Gearman Consumer receive should only fetch one message [\#998](https://github.com/php-enqueue/enqueue-dev/pull/998) ([arep](https://github.com/arep)) +- \[sqs\] add messageId to the sqsMessage [\#992](https://github.com/php-enqueue/enqueue-dev/pull/992) ([BenoitLeveque](https://github.com/BenoitLeveque)) + +## [0.10.0](https://github.com/php-enqueue/enqueue-dev/tree/0.10.0) (2019-12-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.15...0.10.0) + +**Merged pull requests:** + +- Symfony 5 [\#997](https://github.com/php-enqueue/enqueue-dev/pull/997) ([kuraobi](https://github.com/kuraobi)) +- Replace the Magento 1 code into the Magento 2 documentation [\#999](https://github.com/php-enqueue/enqueue-dev/pull/999) ([hochgenug](https://github.com/hochgenug)) +- Wrong parameter description [\#994](https://github.com/php-enqueue/enqueue-dev/pull/994) ([bramstroker](https://github.com/bramstroker)) + +## [0.9.15](https://github.com/php-enqueue/enqueue-dev/tree/0.9.15) (2019-11-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.14...0.9.15) + +**Merged pull requests:** + +- Fix Incompatibility for doctrine [\#988](https://github.com/php-enqueue/enqueue-dev/pull/988) ([Baachi](https://github.com/Baachi)) +- Prefer early returns in consumer code [\#982](https://github.com/php-enqueue/enqueue-dev/pull/982) ([Steveb-p](https://github.com/Steveb-p)) +- \#977 - Fix issues with MS SQL server and dbal transport [\#979](https://github.com/php-enqueue/enqueue-dev/pull/979) ([NeilWhitworth](https://github.com/NeilWhitworth)) +- Add header support for Symfony's produce command [\#965](https://github.com/php-enqueue/enqueue-dev/pull/965) ([TiMESPLiNTER](https://github.com/TiMESPLiNTER)) + +## [0.9.14](https://github.com/php-enqueue/enqueue-dev/tree/0.9.14) (2019-10-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.13...0.9.14) + +**Merged pull requests:** + +- Fix deprecated heartbeat check method [\#967](https://github.com/php-enqueue/enqueue-dev/pull/967) ([ramunasd](https://github.com/ramunasd)) +- Add missing rabbitmq DSN example [\#966](https://github.com/php-enqueue/enqueue-dev/pull/966) ([ramunasd](https://github.com/ramunasd)) +- Fix empty class for autowired services \(Fix \#957\) [\#958](https://github.com/php-enqueue/enqueue-dev/pull/958) ([NicolasGuilloux](https://github.com/NicolasGuilloux)) +- Add header support for kafka [\#955](https://github.com/php-enqueue/enqueue-dev/pull/955) ([TiMESPLiNTER](https://github.com/TiMESPLiNTER)) +- Kafka singleton consumer [\#947](https://github.com/php-enqueue/enqueue-dev/pull/947) ([dirk39](https://github.com/dirk39)) + +## [0.9.13](https://github.com/php-enqueue/enqueue-dev/tree/0.9.13) (2019-09-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.12...0.9.13) + +**Merged pull requests:** + +- docs: describe drawbacks of using amqp extension [\#942](https://github.com/php-enqueue/enqueue-dev/pull/942) ([gnumoksha](https://github.com/gnumoksha)) +- Add a service to reset doctrine/odm identity maps [\#933](https://github.com/php-enqueue/enqueue-dev/pull/933) ([Lctrs](https://github.com/Lctrs)) +- Add an extension to stop consumption on closed entity manager [\#932](https://github.com/php-enqueue/enqueue-dev/pull/932) ([Lctrs](https://github.com/Lctrs)) +- Add an extension to reset services [\#929](https://github.com/php-enqueue/enqueue-dev/pull/929) ([Lctrs](https://github.com/Lctrs)) +- \[DoctrineClearIdentityMapExtension\] allow instances of ManagerRegistry [\#927](https://github.com/php-enqueue/enqueue-dev/pull/927) ([Lctrs](https://github.com/Lctrs)) +- Link to documentation from logo [\#926](https://github.com/php-enqueue/enqueue-dev/pull/926) ([Steveb-p](https://github.com/Steveb-p)) +- DBAL Change ParameterType class to Type class [\#916](https://github.com/php-enqueue/enqueue-dev/pull/916) ([Nevoss](https://github.com/Nevoss)) +- async\_commands: extended configuration proposal [\#914](https://github.com/php-enqueue/enqueue-dev/pull/914) ([uro](https://github.com/uro)) + +## [0.9.12](https://github.com/php-enqueue/enqueue-dev/tree/0.9.12) (2019-06-25) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.11...0.9.12) + +**Merged pull requests:** + +- \[SNSQS\] Fix issue with delay [\#909](https://github.com/php-enqueue/enqueue-dev/pull/909) ([uro](https://github.com/uro)) +- \[SNS\] Fix: Missing throw issue [\#908](https://github.com/php-enqueue/enqueue-dev/pull/908) ([uro](https://github.com/uro)) +- \[SNS\] Adding generic driver for schema SNS [\#906](https://github.com/php-enqueue/enqueue-dev/pull/906) ([Nyholm](https://github.com/Nyholm)) +- \[SQS\] deserialize sqs message attributes [\#901](https://github.com/php-enqueue/enqueue-dev/pull/901) ([bendavies](https://github.com/bendavies)) +- \[SNS\] Updates dependencies requirements for sns\(qs\) [\#899](https://github.com/php-enqueue/enqueue-dev/pull/899) ([xavismeh](https://github.com/xavismeh)) +- Cast int for redelivery\_delay and polling\_interval [\#896](https://github.com/php-enqueue/enqueue-dev/pull/896) ([linh4github](https://github.com/linh4github)) +- \[doc\] Move support note to an external include file [\#892](https://github.com/php-enqueue/enqueue-dev/pull/892) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Allow reading headers from Kafka Message headers [\#891](https://github.com/php-enqueue/enqueue-dev/pull/891) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Fix Code Style in all files [\#889](https://github.com/php-enqueue/enqueue-dev/pull/889) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Move "key concepts" to second position in menu. Fix typos. [\#886](https://github.com/php-enqueue/enqueue-dev/pull/886) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\]\[Bundle\] Expand quick tour for Symfony Bundle [\#885](https://github.com/php-enqueue/enqueue-dev/pull/885) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Fix link for cli commands [\#882](https://github.com/php-enqueue/enqueue-dev/pull/882) ([samnela](https://github.com/samnela)) +- Add composer runnable scripts for PHPStan & PHP-CS [\#881](https://github.com/php-enqueue/enqueue-dev/pull/881) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Fixed quick tour link [\#878](https://github.com/php-enqueue/enqueue-dev/pull/878) ([samnela](https://github.com/samnela)) +- \[doc\] Fix documentation links [\#877](https://github.com/php-enqueue/enqueue-dev/pull/877) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Add editor config settings for IDE's that support it [\#875](https://github.com/php-enqueue/enqueue-dev/pull/875) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Prefer github pages in packages' readme files [\#874](https://github.com/php-enqueue/enqueue-dev/pull/874) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Add Amazon SNS documentation placeholder [\#873](https://github.com/php-enqueue/enqueue-dev/pull/873) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Prefer github pages in readme [\#872](https://github.com/php-enqueue/enqueue-dev/pull/872) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Github Pages - Match topic order from index.md [\#870](https://github.com/php-enqueue/enqueue-dev/pull/870) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Github pages navigation structure [\#869](https://github.com/php-enqueue/enqueue-dev/pull/869) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Fixed the service id for Transport [\#868](https://github.com/php-enqueue/enqueue-dev/pull/868) ([samnela](https://github.com/samnela)) +- \[doc\] Use organization repository for doc hosting [\#867](https://github.com/php-enqueue/enqueue-dev/pull/867) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Switch documentation to github pages [\#866](https://github.com/php-enqueue/enqueue-dev/pull/866) ([Steveb-p](https://github.com/Steveb-p)) +- Prefer stable dependencies for development [\#865](https://github.com/php-enqueue/enqueue-dev/pull/865) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] Key concepts [\#863](https://github.com/php-enqueue/enqueue-dev/pull/863) ([sylfabre](https://github.com/sylfabre)) +- \[doc\] Better Symfony doc nav [\#862](https://github.com/php-enqueue/enqueue-dev/pull/862) ([sylfabre](https://github.com/sylfabre)) + +## [0.9.11](https://github.com/php-enqueue/enqueue-dev/tree/0.9.11) (2019-05-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.10...0.9.11) + +**Merged pull requests:** + +- \[client\] Fix --logger option. Removed unintentionally set console logger. [\#861](https://github.com/php-enqueue/enqueue-dev/pull/861) ([makasim](https://github.com/makasim)) +- \[client\] Fix reference to logger service. [\#860](https://github.com/php-enqueue/enqueue-dev/pull/860) ([makasim](https://github.com/makasim)) +- \[consumption\] Fix bindCallback method will require new arg deprecation notice [\#859](https://github.com/php-enqueue/enqueue-dev/pull/859) ([makasim](https://github.com/makasim)) +- \[amqp-bunny\] Revert "Fix heartbeat configuration in bunny with 0 \(off\) value" [\#855](https://github.com/php-enqueue/enqueue-dev/pull/855) ([DamienHarper](https://github.com/DamienHarper)) +- \[sqs\] Requeue with a visibility timeout [\#852](https://github.com/php-enqueue/enqueue-dev/pull/852) ([deguif](https://github.com/deguif)) +- \[monitoring\] Send topic and command for consumed messages [\#849](https://github.com/php-enqueue/enqueue-dev/pull/849) ([mariusbalcytis](https://github.com/mariusbalcytis)) +- Fixed typo [\#856](https://github.com/php-enqueue/enqueue-dev/pull/856) ([samnela](https://github.com/samnela)) + +## [0.9.10](https://github.com/php-enqueue/enqueue-dev/tree/0.9.10) (2019-05-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.9...0.9.10) + +**Merged pull requests:** + +- \[client\] Lazy producer. [\#845](https://github.com/php-enqueue/enqueue-dev/pull/845) ([makasim](https://github.com/makasim)) +- \[kafka\] Fix consumption errors in kafka against recent versions in librdkafka/phprdkafka [\#842](https://github.com/php-enqueue/enqueue-dev/pull/842) ([Steveb-p](https://github.com/Steveb-p)) +- \[amqp-lib\] Fix un-initialized property use [\#836](https://github.com/php-enqueue/enqueue-dev/pull/836) ([Steveb-p](https://github.com/Steveb-p)) +- \[amqp-bunny\] Fix heartbeat configuration in bunny with 0 \(off\) value [\#820](https://github.com/php-enqueue/enqueue-dev/pull/820) ([nightlinus](https://github.com/nightlinus)) +- \[stomp\] Add support for using the /topic prefix instead of /exchange. [\#826](https://github.com/php-enqueue/enqueue-dev/pull/826) ([alessandroniciforo](https://github.com/alessandroniciforo)) +- \[sns\] Allow setting SNS message attributes, other fields [\#799](https://github.com/php-enqueue/enqueue-dev/pull/799) ([aldenw](https://github.com/aldenw)) +- Fixed docs [\#822](https://github.com/php-enqueue/enqueue-dev/pull/822) ([Toflar](https://github.com/Toflar)) +- Typo on the tag [\#818](https://github.com/php-enqueue/enqueue-dev/pull/818) ([appeltaert](https://github.com/appeltaert)) + +## [0.9.9](https://github.com/php-enqueue/enqueue-dev/tree/0.9.9) (2019-04-04) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.8...0.9.9) + +**Merged pull requests:** + +- \[amqp-bunny\] Fix bunny producer to properly map headers to expected by bunny headers [\#816](https://github.com/php-enqueue/enqueue-dev/pull/816) ([nightlinus](https://github.com/nightlinus)) +- \[amqp-bunny\]\[doc\] Update amqp\_bunny.md [\#797](https://github.com/php-enqueue/enqueue-dev/pull/797) ([enumag](https://github.com/enumag)) +- \[dbal\] Fix DBAL Consumer duplicating messages when rejecting with requeue [\#815](https://github.com/php-enqueue/enqueue-dev/pull/815) ([Steveb-p](https://github.com/Steveb-p)) +- \[rdkafka\] Set `commit\_async` as true by default for Kafka, update docs [\#810](https://github.com/php-enqueue/enqueue-dev/pull/810) ([Steveb-p](https://github.com/Steveb-p)) +- \[rdkafka\] stats\_cb support [\#798](https://github.com/php-enqueue/enqueue-dev/pull/798) ([fkulakov](https://github.com/fkulakov)) +- \[Monitoring\]\[InfluxDB\] Allow passing Client as configuration option. [\#809](https://github.com/php-enqueue/enqueue-dev/pull/809) ([Steveb-p](https://github.com/Steveb-p)) +- \[doc\] better doc for traceable message producer [\#813](https://github.com/php-enqueue/enqueue-dev/pull/813) ([sylfabre](https://github.com/sylfabre)) +- \[doc\] Minor typo fix in docblock [\#805](https://github.com/php-enqueue/enqueue-dev/pull/805) ([gpenverne](https://github.com/gpenverne)) +- fix comment on QueueConsumer constructor [\#796](https://github.com/php-enqueue/enqueue-dev/pull/796) ([kaznovac](https://github.com/kaznovac)) + +## [0.9.8](https://github.com/php-enqueue/enqueue-dev/tree/0.9.8) (2019-02-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.7...0.9.8) + +**Merged pull requests:** + +- Add upgrade instructions [\#787](https://github.com/php-enqueue/enqueue-dev/pull/787) ([KDederichs](https://github.com/KDederichs)) +- \[consumption\] Fix exception loop in QueueConsumer [\#776](https://github.com/php-enqueue/enqueue-dev/pull/776) ([enumag](https://github.com/enumag)) +- \[consumption\] Add ability to change process exit status from within queue consumer extension [\#766](https://github.com/php-enqueue/enqueue-dev/pull/766) ([greblov](https://github.com/greblov)) +- \[amqp-tools\] Fix amqp-tools dependency [\#785](https://github.com/php-enqueue/enqueue-dev/pull/785) ([TomPradat](https://github.com/TomPradat)) +- \[amqp-tools\] Enable 'ssl\_on' param for 'ssl' scheme extension [\#781](https://github.com/php-enqueue/enqueue-dev/pull/781) ([Leprechaunz](https://github.com/Leprechaunz)) +- \[amqp-bunny\] Catch signal in Bunny adapter [\#771](https://github.com/php-enqueue/enqueue-dev/pull/771) ([snapshotpl](https://github.com/snapshotpl)) +- \[amqp-lib\] supporting channel\_rpc\_timeout option [\#755](https://github.com/php-enqueue/enqueue-dev/pull/755) ([derek9gag](https://github.com/derek9gag)) +- \[dbal\]: make dbal connection config usable again [\#765](https://github.com/php-enqueue/enqueue-dev/pull/765) ([ssiergl](https://github.com/ssiergl)) +- \[fs\] polling\_interval config should be milliseconds not microseconds [\#764](https://github.com/php-enqueue/enqueue-dev/pull/764) ([ssiergl](https://github.com/ssiergl)) +- \[simple-client\] Fix Logger Initialisation [\#752](https://github.com/php-enqueue/enqueue-dev/pull/752) ([ajbonner](https://github.com/ajbonner)) +- \[snsqs\] Corrected the installation part in the docs/transport/snsqs.md [\#791](https://github.com/php-enqueue/enqueue-dev/pull/791) ([dgreda](https://github.com/dgreda)) +- \[sqs\] Update SqsConnectionFactory.php [\#751](https://github.com/php-enqueue/enqueue-dev/pull/751) ([Orkin](https://github.com/Orkin)) +- correct typo in composer.json [\#767](https://github.com/php-enqueue/enqueue-dev/pull/767) ([greblov](https://github.com/greblov)) + +## [0.9.7](https://github.com/php-enqueue/enqueue-dev/tree/0.9.7) (2019-02-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.6...0.9.7) + +**Merged pull requests:** + +- Avoid OutOfMemoryException [\#725](https://github.com/php-enqueue/enqueue-dev/pull/725) ([DamienHarper](https://github.com/DamienHarper)) +- \[async-event-dispatcher\] Add default to php\_serializer\_event\_transformer [\#748](https://github.com/php-enqueue/enqueue-dev/pull/748) ([GCalmels](https://github.com/GCalmels)) +- \[async-event-dispatcher\] Fixed param on EventTransformer [\#736](https://github.com/php-enqueue/enqueue-dev/pull/736) ([samnela](https://github.com/samnela)) +- \[job-queue\] Install stable dependencies [\#745](https://github.com/php-enqueue/enqueue-dev/pull/745) ([mbabic131](https://github.com/mbabic131)) +- \[job-queue\] Fix job status processor [\#735](https://github.com/php-enqueue/enqueue-dev/pull/735) ([ASKozienko](https://github.com/ASKozienko)) +- \[redis\] Fix messages sent with incorrect delivery delay [\#738](https://github.com/php-enqueue/enqueue-dev/pull/738) ([niels-nijens](https://github.com/niels-nijens)) +- \[dbal\] Exception on affected record !=1 [\#733](https://github.com/php-enqueue/enqueue-dev/pull/733) ([otzy](https://github.com/otzy)) +- \[bundle\]\[dbal\] Use doctrine bundle configured connections [\#732](https://github.com/php-enqueue/enqueue-dev/pull/732) ([ASKozienko](https://github.com/ASKozienko)) +- \[pheanstalk\] Add unit tests for PheanstalkConsumer [\#726](https://github.com/php-enqueue/enqueue-dev/pull/726) ([alanpoulain](https://github.com/alanpoulain)) +- \[pheanstalk\] Requeuing a message should not acknowledge it beforehand [\#722](https://github.com/php-enqueue/enqueue-dev/pull/722) ([alanpoulain](https://github.com/alanpoulain)) +- \[sqs\] Dead Letter Queue Adoption [\#720](https://github.com/php-enqueue/enqueue-dev/pull/720) ([cshum](https://github.com/cshum)) + +## [0.9.6](https://github.com/php-enqueue/enqueue-dev/tree/0.9.6) (2019-01-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.5...0.9.6) + +**Merged pull requests:** + +- Fix async command/event pkgs [\#717](https://github.com/php-enqueue/enqueue-dev/pull/717) ([GCalmels](https://github.com/GCalmels)) +- Use database from config in PRedis driver [\#715](https://github.com/php-enqueue/enqueue-dev/pull/715) ([lalov](https://github.com/lalov)) +- \[monitoring\] Add support of Datadog [\#716](https://github.com/php-enqueue/enqueue-dev/pull/716) ([uro](https://github.com/uro)) +- \[monitoring\] Fixed influxdb write on sentMessageStats [\#712](https://github.com/php-enqueue/enqueue-dev/pull/712) ([uro](https://github.com/uro)) +- \[monitoring\] Add support for minimum stability - stable [\#711](https://github.com/php-enqueue/enqueue-dev/pull/711) ([uro](https://github.com/uro)) +- \[consumption\] fix wrong niceness extension param [\#709](https://github.com/php-enqueue/enqueue-dev/pull/709) ([ramunasd](https://github.com/ramunasd)) + +## [0.9.5](https://github.com/php-enqueue/enqueue-dev/tree/0.9.5) (2018-12-21) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.4...0.9.5) + +**Merged pull requests:** + +- \[dbal\] Run tests on PostgreSQS [\#705](https://github.com/php-enqueue/enqueue-dev/pull/705) ([makasim](https://github.com/makasim)) +- \[dbal\] Use string-based UUIDs instead of binary [\#698](https://github.com/php-enqueue/enqueue-dev/pull/698) ([jverdeyen](https://github.com/jverdeyen)) + +## [0.9.4](https://github.com/php-enqueue/enqueue-dev/tree/0.9.4) (2018-12-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.3...0.9.4) + +**Merged pull requests:** + +- \[client\] sendToProcessor should able to send message to router processor. [\#703](https://github.com/php-enqueue/enqueue-dev/pull/703) ([makasim](https://github.com/makasim)) +- \[client\] Fix SetRouterPropertiesExtension should skip no topic messages. [\#702](https://github.com/php-enqueue/enqueue-dev/pull/702) ([makasim](https://github.com/makasim)) +- \[client\] Fix Exclusive Command Extension ignores route queue prefix option. [\#701](https://github.com/php-enqueue/enqueue-dev/pull/701) ([makasim](https://github.com/makasim)) +- \[amqp\] fix \#696 parsing vhost from amqp dsn [\#697](https://github.com/php-enqueue/enqueue-dev/pull/697) ([rpanfili](https://github.com/rpanfili)) +- \[doc\] Fix link to declare queue [\#699](https://github.com/php-enqueue/enqueue-dev/pull/699) ([samnela](https://github.com/samnela)) + +## [0.9.3](https://github.com/php-enqueue/enqueue-dev/tree/0.9.3) (2018-12-17) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.2...0.9.3) + +**Merged pull requests:** + +- Fix async command package [\#694](https://github.com/php-enqueue/enqueue-dev/pull/694) ([makasim](https://github.com/makasim)) +- Fix async events package [\#694](https://github.com/php-enqueue/enqueue-dev/pull/694) ([makasim](https://github.com/makasim)) +- Add commands for single transport\client with typed arguments. [\#693](https://github.com/php-enqueue/enqueue-dev/pull/693) ([makasim](https://github.com/makasim)) +- Fix TreeBuilder in Symfony 4.2 [\#692](https://github.com/php-enqueue/enqueue-dev/pull/692) ([angelsk](https://github.com/angelsk)) +- [doc] update docs [\#689](https://github.com/php-enqueue/enqueue-dev/pull/689) ([OskarStark](https://github.com/OskarStark)) + +## [0.9.2](https://github.com/php-enqueue/enqueue-dev/tree/0.9.2) (2018-12-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.1...0.9.2) + +**Merged pull requests:** + +- Allow 0.8.x Queue Interop \(without deprecated Psr prefixed interfaces\) [\#688](https://github.com/php-enqueue/enqueue-dev/pull/688) ([makasim](https://github.com/makasim)) +- \[dsn\] remove commented out code [\#661](https://github.com/php-enqueue/enqueue-dev/pull/661) ([kunicmarko20](https://github.com/kunicmarko20)) +- \[fs\]: fix: Wrong parameters for Exception [\#678](https://github.com/php-enqueue/enqueue-dev/pull/678) ([ssiergl](https://github.com/ssiergl)) +- \[fs\] Do not throw error in jsonUnserialize on deprecation notice [\#671](https://github.com/php-enqueue/enqueue-dev/pull/671) ([ssiergl](https://github.com/ssiergl)) +- \[mongodb\] polling\_integer type not correctly handled when using DSN [\#673](https://github.com/php-enqueue/enqueue-dev/pull/673) ([jak](https://github.com/jak)) +- \[dbal\] Use ordered bytes time uuid codec on message id decode. [\#665](https://github.com/php-enqueue/enqueue-dev/pull/665) ([makasim](https://github.com/makasim)) +- \[dbal\] fix: Wrong parameters for Exception [\#676](https://github.com/php-enqueue/enqueue-dev/pull/676) ([Nommyde](https://github.com/Nommyde)) +- \[sqs\] Add ability to use another aws account per queue. [\#666](https://github.com/php-enqueue/enqueue-dev/pull/666) ([makasim](https://github.com/makasim)) +- \[sqs\] Multi region support [\#664](https://github.com/php-enqueue/enqueue-dev/pull/664) ([makasim](https://github.com/makasim)) +- \[sqs\] Use a queue created in another AWS account. [\#662](https://github.com/php-enqueue/enqueue-dev/pull/662) ([makasim](https://github.com/makasim)) +- \[job-queue\] Fix tests on newer dbal versions. [\#687](https://github.com/php-enqueue/enqueue-dev/pull/687) ([makasim](https://github.com/makasim)) +- [doc] typo [\#686](https://github.com/php-enqueue/enqueue-dev/pull/686) ([OskarStark](https://github.com/OskarStark)) +- [doc] typo [\#683](https://github.com/php-enqueue/enqueue-dev/pull/683) ([OskarStark](https://github.com/OskarStark)) +- [doc] Fix package name for redis [\#680](https://github.com/php-enqueue/enqueue-dev/pull/680) ([gnumoksha](https://github.com/gnumoksha)) + +## [0.9.1](https://github.com/php-enqueue/enqueue-dev/tree/0.9.1) (2018-11-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.9.0...0.9.1) + +**Merged pull requests:** + +- Allow installing stable dependencies. [\#660](https://github.com/php-enqueue/enqueue-dev/pull/660) ([makasim](https://github.com/makasim)) + +## [0.9.0](https://github.com/php-enqueue/enqueue-dev/tree/0.9) (2018-11-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.42...0.9) + +**Merged pull requests:** + +- \[amqp\]\[lib\] Improve heartbeat handling. Introduce heartbeat on tick. Fixes "Invalid frame type 65" and "Broken pipe or closed connection" [\#658](https://github.com/php-enqueue/enqueue-dev/pull/658) ([makasim](https://github.com/makasim)) +- Redis dsn and password fixes [\#656](https://github.com/php-enqueue/enqueue-dev/pull/656) ([makasim](https://github.com/makasim)) +- Fix ping to check each connection, not only first one [\#651](https://github.com/php-enqueue/enqueue-dev/pull/651) ([webmake](https://github.com/webmake)) +- Rework DriverFactory, add separator option to Client Config. [\#646](https://github.com/php-enqueue/enqueue-dev/pull/646) ([makasim](https://github.com/makasim)) +- \[dsn\] Parse DSN Cluster [\#643](https://github.com/php-enqueue/enqueue-dev/pull/643) ([makasim](https://github.com/makasim)) +- \[dbal\] Use RetryableException, wrap fetchMessage exception to it too. [\#642](https://github.com/php-enqueue/enqueue-dev/pull/642) ([makasim](https://github.com/makasim)) +- \[bundle\] Add BC for topic\command subscribers. [\#641](https://github.com/php-enqueue/enqueue-dev/pull/641) ([makasim](https://github.com/makasim)) +- \[dbal\] handle gracefully concurrency issues or 3rd party interruptions. [\#640](https://github.com/php-enqueue/enqueue-dev/pull/640) ([makasim](https://github.com/makasim)) +- Fix compiler pass [\#639](https://github.com/php-enqueue/enqueue-dev/pull/639) ([ASKozienko](https://github.com/ASKozienko)) +- Fix wrong exceptions in transports [\#637](https://github.com/php-enqueue/enqueue-dev/pull/637) ([FrankGiesecke](https://github.com/FrankGiesecke)) +- Enable job-queue for default configuration [\#636](https://github.com/php-enqueue/enqueue-dev/pull/636) ([ASKozienko](https://github.com/ASKozienko)) +- better readability [\#632](https://github.com/php-enqueue/enqueue-dev/pull/632) ([OskarStark](https://github.com/OskarStark)) +- Fixed headline [\#631](https://github.com/php-enqueue/enqueue-dev/pull/631) ([OskarStark](https://github.com/OskarStark)) +- \[bundle\] Multi Client Configuration [\#628](https://github.com/php-enqueue/enqueue-dev/pull/628) ([ASKozienko](https://github.com/ASKozienko)) +- removed some dots [\#627](https://github.com/php-enqueue/enqueue-dev/pull/627) ([OskarStark](https://github.com/OskarStark)) +- Avoid receiveNoWait when only one subscriber [\#626](https://github.com/php-enqueue/enqueue-dev/pull/626) ([deguif](https://github.com/deguif)) +- Add context services to locator [\#623](https://github.com/php-enqueue/enqueue-dev/pull/623) ([Gnucki](https://github.com/Gnucki)) +- \[doc\]\[skip ci\] Add sponsoring section. [\#618](https://github.com/php-enqueue/enqueue-dev/pull/618) ([makasim](https://github.com/makasim)) +- Merge 0.8x -\> 0.9x [\#617](https://github.com/php-enqueue/enqueue-dev/pull/617) ([ASKozienko](https://github.com/ASKozienko)) +- Compatibility with 0.8x [\#616](https://github.com/php-enqueue/enqueue-dev/pull/616) ([ASKozienko](https://github.com/ASKozienko)) +- \[dbal\] Use concurrent fetch message approach \(no transaction, no pessimistic lock\) [\#613](https://github.com/php-enqueue/enqueue-dev/pull/613) ([makasim](https://github.com/makasim)) +- \[fs\] Use enqueue/dsn to parse DSN [\#612](https://github.com/php-enqueue/enqueue-dev/pull/612) ([makasim](https://github.com/makasim)) +- \[client\]\[bundle\] Take queue prefix into account while queue binding. [\#611](https://github.com/php-enqueue/enqueue-dev/pull/611) ([makasim](https://github.com/makasim)) +- Add support for the 'ciphers' ssl option [\#607](https://github.com/php-enqueue/enqueue-dev/pull/607) ([eperazzo](https://github.com/eperazzo)) +- Queue monitoring. [\#606](https://github.com/php-enqueue/enqueue-dev/pull/606) ([ASKozienko](https://github.com/ASKozienko)) +- Fix comment about queue deletion [\#604](https://github.com/php-enqueue/enqueue-dev/pull/604) ([a-ast](https://github.com/a-ast)) +- \[docs\] Fixed docs. Removed prefix Psr. [\#603](https://github.com/php-enqueue/enqueue-dev/pull/603) ([yurez](https://github.com/yurez)) +- fix wamp [\#597](https://github.com/php-enqueue/enqueue-dev/pull/597) ([ASKozienko](https://github.com/ASKozienko)) +- \[doc\]\[skip ci\] Add supporting section [\#595](https://github.com/php-enqueue/enqueue-dev/pull/595) ([makasim](https://github.com/makasim)) +- Do not export non source files [\#588](https://github.com/php-enqueue/enqueue-dev/pull/588) ([webmake](https://github.com/webmake)) +- Redis New Implementation [\#585](https://github.com/php-enqueue/enqueue-dev/pull/585) ([ASKozienko](https://github.com/ASKozienko)) +- Fix Redis Tests [\#582](https://github.com/php-enqueue/enqueue-dev/pull/582) ([ASKozienko](https://github.com/ASKozienko)) +- \[dbal\] Introduce redelivery support based on visibility approach. [\#581](https://github.com/php-enqueue/enqueue-dev/pull/581) ([rosamarsky](https://github.com/rosamarsky)) +- fix redis tests [\#578](https://github.com/php-enqueue/enqueue-dev/pull/578) ([ASKozienko](https://github.com/ASKozienko)) +- \[client\] Make symfony compiler passes multi client [\#577](https://github.com/php-enqueue/enqueue-dev/pull/577) ([makasim](https://github.com/makasim)) +- Removed predis from composer.json [\#576](https://github.com/php-enqueue/enqueue-dev/pull/576) ([rosamarsky](https://github.com/rosamarsky)) +- Added index for queue field in the enqueue collection [\#574](https://github.com/php-enqueue/enqueue-dev/pull/574) ([rosamarsky](https://github.com/rosamarsky)) +- WAMP [\#573](https://github.com/php-enqueue/enqueue-dev/pull/573) ([ASKozienko](https://github.com/ASKozienko)) +- Bundle multi transport configuration [\#572](https://github.com/php-enqueue/enqueue-dev/pull/572) ([makasim](https://github.com/makasim)) +- \[client\] Move client config to the factory. [\#571](https://github.com/php-enqueue/enqueue-dev/pull/571) ([makasim](https://github.com/makasim)) +- Update quick\_tour.md [\#569](https://github.com/php-enqueue/enqueue-dev/pull/569) ([luceos](https://github.com/luceos)) +- \[rdkafka\] Use default queue as router topic [\#567](https://github.com/php-enqueue/enqueue-dev/pull/567) ([rosamarsky](https://github.com/rosamarsky)) +- Fixing composer.json to require enqueue/dsn [\#566](https://github.com/php-enqueue/enqueue-dev/pull/566) ([adumas37](https://github.com/adumas37)) +- MongoDB Subscription Consumer feature [\#565](https://github.com/php-enqueue/enqueue-dev/pull/565) ([rosamarsky](https://github.com/rosamarsky)) +- Remove deprecated testcase implementation [\#564](https://github.com/php-enqueue/enqueue-dev/pull/564) ([samnela](https://github.com/samnela)) +- Dbal Subscription Consumer feature [\#563](https://github.com/php-enqueue/enqueue-dev/pull/563) ([rosamarsky](https://github.com/rosamarsky)) +- \[client\] Move services definition to ClientFactory. [\#556](https://github.com/php-enqueue/enqueue-dev/pull/556) ([makasim](https://github.com/makasim)) +- Fixed exception message in testThrowErrorIfServiceDoesNotImplementProcessorReturnType [\#559](https://github.com/php-enqueue/enqueue-dev/pull/559) ([rosamarsky](https://github.com/rosamarsky)) +- Update supported\_brokers.md [\#558](https://github.com/php-enqueue/enqueue-dev/pull/558) ([edgji](https://github.com/edgji)) +- \[consumption\] Logging improvements [\#555](https://github.com/php-enqueue/enqueue-dev/pull/555) ([makasim](https://github.com/makasim)) +- \[consumption\] Rework QueueConsumer extension points. [\#554](https://github.com/php-enqueue/enqueue-dev/pull/554) ([makasim](https://github.com/makasim)) +- \[STOMP\] make getStomp public [\#552](https://github.com/php-enqueue/enqueue-dev/pull/552) ([versh23](https://github.com/versh23)) +- \[consumption\] Add ability to consume from multiple transports. [\#548](https://github.com/php-enqueue/enqueue-dev/pull/548) ([makasim](https://github.com/makasim)) +- \[client\] Rename config options. [\#547](https://github.com/php-enqueue/enqueue-dev/pull/547) ([makasim](https://github.com/makasim)) +- Remove config parameters [\#545](https://github.com/php-enqueue/enqueue-dev/pull/545) ([makasim](https://github.com/makasim)) +- Remove transport factories [\#544](https://github.com/php-enqueue/enqueue-dev/pull/544) ([makasim](https://github.com/makasim)) +- Remove psr prefix [\#543](https://github.com/php-enqueue/enqueue-dev/pull/543) ([makasim](https://github.com/makasim)) +- \[amqp\] Set delay strategy if rabbitmq scheme extension present. [\#536](https://github.com/php-enqueue/enqueue-dev/pull/536) ([makasim](https://github.com/makasim)) +- \[client\] Add type hints to driver interface and its implementations. [\#535](https://github.com/php-enqueue/enqueue-dev/pull/535) ([makasim](https://github.com/makasim)) +- \[client\] Introduce routes. Foundation for multi transport support. [\#534](https://github.com/php-enqueue/enqueue-dev/pull/534) ([makasim](https://github.com/makasim)) +- \[gps\] enhance connection configuration. [\#531](https://github.com/php-enqueue/enqueue-dev/pull/531) ([makasim](https://github.com/makasim)) +- \[sqs\] Configuration enhancements [\#530](https://github.com/php-enqueue/enqueue-dev/pull/530) ([makasim](https://github.com/makasim)) +- \[redis\] Improve redis config, use enqueue/dsn [\#528](https://github.com/php-enqueue/enqueue-dev/pull/528) ([makasim](https://github.com/makasim)) +- \[dsn\] Add typed methods for query parameters. [\#527](https://github.com/php-enqueue/enqueue-dev/pull/527) ([makasim](https://github.com/makasim)) +- \[redis\] Revert timeout change. [\#526](https://github.com/php-enqueue/enqueue-dev/pull/526) ([makasim](https://github.com/makasim)) +- \[Redis\] Add support of secure\TLS connections \(based on PR 515\) [\#524](https://github.com/php-enqueue/enqueue-dev/pull/524) ([makasim](https://github.com/makasim)) +- Simplify Enqueue configuration. [\#522](https://github.com/php-enqueue/enqueue-dev/pull/522) ([makasim](https://github.com/makasim)) +- \[client\] Add typehints to producer interface, its implementations [\#521](https://github.com/php-enqueue/enqueue-dev/pull/521) ([makasim](https://github.com/makasim)) +- \[client\] Improve client extension. [\#517](https://github.com/php-enqueue/enqueue-dev/pull/517) ([makasim](https://github.com/makasim)) +- Add declare strict [\#516](https://github.com/php-enqueue/enqueue-dev/pull/516) ([makasim](https://github.com/makasim)) +- PHP 7.1+. Queue Interop typed interfaces. [\#512](https://github.com/php-enqueue/enqueue-dev/pull/512) ([makasim](https://github.com/makasim)) +- \[Symfony\] default factory should resolve DSN in runtime [\#510](https://github.com/php-enqueue/enqueue-dev/pull/510) ([makasim](https://github.com/makasim)) +- Fixed password auth for predis [\#509](https://github.com/php-enqueue/enqueue-dev/pull/509) ([Toflar](https://github.com/Toflar)) +- Allow either subscribe or assign in RdKafkaConsumer [\#508](https://github.com/php-enqueue/enqueue-dev/pull/508) ([Engerim](https://github.com/Engerim)) +- Remove deprecated in 0.8 code [\#507](https://github.com/php-enqueue/enqueue-dev/pull/507) ([makasim](https://github.com/makasim)) +- Run tests on rabbitmq 3.7 [\#506](https://github.com/php-enqueue/enqueue-dev/pull/506) ([makasim](https://github.com/makasim)) +- Symfony add default command name [\#505](https://github.com/php-enqueue/enqueue-dev/pull/505) ([makasim](https://github.com/makasim)) +- \[Consumption\] Add QueueConsumerInterface, make QueueConsumer final. [\#504](https://github.com/php-enqueue/enqueue-dev/pull/504) ([makasim](https://github.com/makasim)) +- Redis subscription consumer [\#503](https://github.com/php-enqueue/enqueue-dev/pull/503) ([makasim](https://github.com/makasim)) +- Remove support of old Symfony versions. [\#502](https://github.com/php-enqueue/enqueue-dev/pull/502) ([makasim](https://github.com/makasim)) +- \[BC break\]\[dbal\] Convert between Message::$expire and DbalMessage::$timeToLive [\#501](https://github.com/php-enqueue/enqueue-dev/pull/501) ([makasim](https://github.com/makasim)) +- \[BC break\]\[dbal\] Change columns type from int to bigint. [\#500](https://github.com/php-enqueue/enqueue-dev/pull/500) ([makasim](https://github.com/makasim)) +- \[BC break\]\[dbal\] Fix time conversion in DbalDriver. [\#499](https://github.com/php-enqueue/enqueue-dev/pull/499) ([makasim](https://github.com/makasim)) +- \[BC break\]\[dbal\] Add index, fix performance issue. [\#498](https://github.com/php-enqueue/enqueue-dev/pull/498) ([makasim](https://github.com/makasim)) +- \[redis\] Authentication support added [\#497](https://github.com/php-enqueue/enqueue-dev/pull/497) ([makasim](https://github.com/makasim)) +- add subscription consumer specs to amqp pkgs [\#495](https://github.com/php-enqueue/enqueue-dev/pull/495) ([makasim](https://github.com/makasim)) +- add contribution to subtree split message [\#494](https://github.com/php-enqueue/enqueue-dev/pull/494) ([makasim](https://github.com/makasim)) +- Get rid of path repository [\#493](https://github.com/php-enqueue/enqueue-dev/pull/493) ([makasim](https://github.com/makasim)) +- Move subscription related logic to SubscriptionConsumer class. [\#492](https://github.com/php-enqueue/enqueue-dev/pull/492) ([makasim](https://github.com/makasim)) +- remove bc layer. [\#489](https://github.com/php-enqueue/enqueue-dev/pull/489) ([makasim](https://github.com/makasim)) +- Job Queue: Throw orphan job exception when child job cleanup fails. [\#496](https://github.com/php-enqueue/enqueue-dev/pull/496) ([garrettrayj](https://github.com/garrettrayj)) +- \[bundle\] Fix panel rendering when message body is an object [\#442](https://github.com/php-enqueue/enqueue-dev/pull/442) ([thePanz](https://github.com/thePanz)) +- \[symfony\] Async commands [\#403](https://github.com/php-enqueue/enqueue-dev/pull/403) ([makasim](https://github.com/makasim)) + +## [0.8.42](https://github.com/php-enqueue/enqueue-dev/tree/0.8.42) (2018-11-22) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.41...0.8.42) + +**Merged pull requests:** + +- Gitattributes backporting [\#654](https://github.com/php-enqueue/enqueue-dev/pull/654) ([webmake](https://github.com/webmake)) + +## [0.8.41](https://github.com/php-enqueue/enqueue-dev/tree/0.8.41) (2018-11-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.40...0.8.41) + +**Merged pull requests:** + +- Compatibility with 0.9x [\#615](https://github.com/php-enqueue/enqueue-dev/pull/615) ([ASKozienko](https://github.com/ASKozienko)) +- Fix Tests 0.8x [\#609](https://github.com/php-enqueue/enqueue-dev/pull/609) ([ASKozienko](https://github.com/ASKozienko)) +- Allow JobStorage to reset the EntityManager [\#586](https://github.com/php-enqueue/enqueue-dev/pull/586) ([damijank](https://github.com/damijank)) +- Fix delay not working on SQS [\#584](https://github.com/php-enqueue/enqueue-dev/pull/584) ([mbeccati](https://github.com/mbeccati)) + +## [0.8.40](https://github.com/php-enqueue/enqueue-dev/tree/0.8.40) (2018-10-22) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.39...0.8.40) + +**Merged pull requests:** + +- \[rdkafka\] Backport changes to topic subscription [\#575](https://github.com/php-enqueue/enqueue-dev/pull/575) ([Steveb-p](https://github.com/Steveb-p)) + +## [0.8.39](https://github.com/php-enqueue/enqueue-dev/tree/0.8.39) (2018-10-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.38...0.8.39) + +**Merged pull requests:** + +- Merge pull request \#552 from versh23/stomp-public [\#568](https://github.com/php-enqueue/enqueue-dev/pull/568) ([versh23](https://github.com/versh23)) + +## [0.8.38](https://github.com/php-enqueue/enqueue-dev/tree/0.8.38) (2018-10-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.37...0.8.38) + +**Merged pull requests:** + +- Fixing kafka default configuration [\#562](https://github.com/php-enqueue/enqueue-dev/pull/562) ([adumas37](https://github.com/adumas37)) +- enableSubscriptionConsumer setter [\#541](https://github.com/php-enqueue/enqueue-dev/pull/541) ([ArnaudTarroux](https://github.com/ArnaudTarroux)) + +## [0.8.37](https://github.com/php-enqueue/enqueue-dev/tree/0.8.37) (2018-09-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.36...0.8.37) + +**Merged pull requests:** + +## [0.8.36](https://github.com/php-enqueue/enqueue-dev/tree/0.8.36) (2018-08-22) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.35...0.8.36) + +**Merged pull requests:** + +- Remove bool typehint for php \< 7 supports [\#513](https://github.com/php-enqueue/enqueue-dev/pull/513) ([ArnaudTarroux](https://github.com/ArnaudTarroux)) + +## [0.8.35](https://github.com/php-enqueue/enqueue-dev/tree/0.8.35) (2018-08-06) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.34...0.8.35) + +**Merged pull requests:** + +- Improve multi queue consumption. [\#488](https://github.com/php-enqueue/enqueue-dev/pull/488) ([makasim](https://github.com/makasim)) + +## [0.8.34](https://github.com/php-enqueue/enqueue-dev/tree/0.8.34) (2018-08-04) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.33...0.8.34) + +**Merged pull requests:** + +- simple client dsn issue [\#486](https://github.com/php-enqueue/enqueue-dev/pull/486) ([makasim](https://github.com/makasim)) +- Update SQS DSN doc sample with mention urlencode [\#484](https://github.com/php-enqueue/enqueue-dev/pull/484) ([dgoujard](https://github.com/dgoujard)) +- Prevent SqsProducer from sending messages with empty bodies [\#478](https://github.com/php-enqueue/enqueue-dev/pull/478) ([elazar](https://github.com/elazar)) + +## [0.8.33](https://github.com/php-enqueue/enqueue-dev/tree/0.8.33) (2018-07-26) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.32...0.8.33) + +**Merged pull requests:** + +- Fix call debug method on null [\#480](https://github.com/php-enqueue/enqueue-dev/pull/480) ([makasim](https://github.com/makasim)) +- Fix AMQPContext::unsubscribe [\#479](https://github.com/php-enqueue/enqueue-dev/pull/479) ([adrienbrault](https://github.com/adrienbrault)) +- Add Localstack Docker container for SQS functional tests [\#473](https://github.com/php-enqueue/enqueue-dev/pull/473) ([elazar](https://github.com/elazar)) +- \[consumption\] add process niceness extension [\#467](https://github.com/php-enqueue/enqueue-dev/pull/467) ([ramunasd](https://github.com/ramunasd)) + +## [0.8.32](https://github.com/php-enqueue/enqueue-dev/tree/0.8.32) (2018-07-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.31...0.8.32) + +**Merged pull requests:** + +- Update of "back to index" link [\#468](https://github.com/php-enqueue/enqueue-dev/pull/468) ([N-M](https://github.com/N-M)) +- PHP\_URL\_SCHEME doesn't support underscores [\#453](https://github.com/php-enqueue/enqueue-dev/pull/453) ([coudenysj](https://github.com/coudenysj)) +- Add autoconfigure for services extending PsrProcess interface [\#452](https://github.com/php-enqueue/enqueue-dev/pull/452) ([mnavarrocarter](https://github.com/mnavarrocarter)) +- WIP: Add support for using a pre-configured client with the SQS driver [\#444](https://github.com/php-enqueue/enqueue-dev/pull/444) ([elazar](https://github.com/elazar)) + +## [0.8.31](https://github.com/php-enqueue/enqueue-dev/tree/0.8.31) (2018-05-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.30...0.8.31) + +**Merged pull requests:** + +- Allow newer version of bunny [\#446](https://github.com/php-enqueue/enqueue-dev/pull/446) ([enumag](https://github.com/enumag)) +- Fix mistype at async\_events docs [\#445](https://github.com/php-enqueue/enqueue-dev/pull/445) ([diimpp](https://github.com/diimpp)) +- Improve exception messages for topic-subscribers [\#441](https://github.com/php-enqueue/enqueue-dev/pull/441) ([thePanz](https://github.com/thePanz)) + +## [0.8.30](https://github.com/php-enqueue/enqueue-dev/tree/0.8.30) (2018-05-08) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.29...0.8.30) + +## [0.8.29](https://github.com/php-enqueue/enqueue-dev/tree/0.8.29) (2018-05-08) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.28...0.8.29) + +**Merged pull requests:** + +- \[mongodb\] Parse DSN if array [\#438](https://github.com/php-enqueue/enqueue-dev/pull/438) ([makasim](https://github.com/makasim)) +- \[gps\] Add support for google/cloud-pubsub ^1.0 [\#437](https://github.com/php-enqueue/enqueue-dev/pull/437) ([kfb-ts](https://github.com/kfb-ts)) +- fix typo in message\_producer.md [\#436](https://github.com/php-enqueue/enqueue-dev/pull/436) ([halidovz](https://github.com/halidovz)) + +## [0.8.28](https://github.com/php-enqueue/enqueue-dev/tree/0.8.28) (2018-05-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.27...0.8.28) + +**Merged pull requests:** + +- remove enqueue core dependency [\#434](https://github.com/php-enqueue/enqueue-dev/pull/434) ([ASKozienko](https://github.com/ASKozienko)) +- Mongodb transport [\#430](https://github.com/php-enqueue/enqueue-dev/pull/430) ([turboboy88](https://github.com/turboboy88)) + +## [0.8.27](https://github.com/php-enqueue/enqueue-dev/tree/0.8.27) (2018-05-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.26...0.8.27) + +**Merged pull requests:** + +- Kafka symfony transport [\#432](https://github.com/php-enqueue/enqueue-dev/pull/432) ([dheineman](https://github.com/dheineman)) +- Drop PHP5 support, Drop Symfony 2.X support. [\#419](https://github.com/php-enqueue/enqueue-dev/pull/419) ([makasim](https://github.com/makasim)) + +## [0.8.26](https://github.com/php-enqueue/enqueue-dev/tree/0.8.26) (2018-04-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.25...0.8.26) + +**Merged pull requests:** + +- Allow to enable SSL in StompConnectionFactory [\#427](https://github.com/php-enqueue/enqueue-dev/pull/427) ([arjanvdbos](https://github.com/arjanvdbos)) +- Fix namespace in doc [\#426](https://github.com/php-enqueue/enqueue-dev/pull/426) ([Koc](https://github.com/Koc)) + +## [0.8.25](https://github.com/php-enqueue/enqueue-dev/tree/0.8.25) (2018-04-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.24...0.8.25) + +**Merged pull requests:** + +- \[skip ci\] Update doc block. return value should be "self" [\#425](https://github.com/php-enqueue/enqueue-dev/pull/425) ([makasim](https://github.com/makasim)) +- \[bundle\] Make TraceableProducer service public [\#422](https://github.com/php-enqueue/enqueue-dev/pull/422) ([sbacelic](https://github.com/sbacelic)) +- Fix a tiny little typo in documentation [\#416](https://github.com/php-enqueue/enqueue-dev/pull/416) ([bobey](https://github.com/bobey)) + +## [0.8.24](https://github.com/php-enqueue/enqueue-dev/tree/0.8.24) (2018-03-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.23...0.8.24) + +**Merged pull requests:** + +- \[bundle\] Don't ping DBAL connection if it wasn't opened [\#414](https://github.com/php-enqueue/enqueue-dev/pull/414) ([ramunasd](https://github.com/ramunasd)) +- Fix AMQP\(s\) code in amqp.md [\#413](https://github.com/php-enqueue/enqueue-dev/pull/413) ([xdbas](https://github.com/xdbas)) +- Fixed typos [\#412](https://github.com/php-enqueue/enqueue-dev/pull/412) ([pborreli](https://github.com/pborreli)) +- Fixed typo [\#411](https://github.com/php-enqueue/enqueue-dev/pull/411) ([pborreli](https://github.com/pborreli)) +- Update sqs transport factory with missing endpoint parameter [\#404](https://github.com/php-enqueue/enqueue-dev/pull/404) ([asilgalis](https://github.com/asilgalis)) +- \[fs\] Escape delimiter symbols. [\#402](https://github.com/php-enqueue/enqueue-dev/pull/402) ([makasim](https://github.com/makasim)) + +## [0.8.23](https://github.com/php-enqueue/enqueue-dev/tree/0.8.23) (2018-03-06) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.22...0.8.23) + +**Merged pull requests:** + +- \[doc\]\[magento2\]\[skip ci\] Add docs for Mangeto2 module. [\#401](https://github.com/php-enqueue/enqueue-dev/pull/401) ([makasim](https://github.com/makasim)) +- Allow queue interop 1.0 alpha. [\#400](https://github.com/php-enqueue/enqueue-dev/pull/400) ([makasim](https://github.com/makasim)) +- Update Travis config to use Symfony 4 release [\#397](https://github.com/php-enqueue/enqueue-dev/pull/397) ([msheakoski](https://github.com/msheakoski)) +- Clean up when a job triggers an exception [\#395](https://github.com/php-enqueue/enqueue-dev/pull/395) ([msheakoski](https://github.com/msheakoski)) + +## [0.8.22](https://github.com/php-enqueue/enqueue-dev/tree/0.8.22) (2018-03-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.21...0.8.22) + +**Merged pull requests:** + +- \[client\] Simple Client should not depend on amqp-ext. [\#389](https://github.com/php-enqueue/enqueue-dev/pull/389) ([makasim](https://github.com/makasim)) +- \[bundle\] fix for "Transport factory with such name already added" [\#388](https://github.com/php-enqueue/enqueue-dev/pull/388) ([makasim](https://github.com/makasim)) +- \[bundle\] add producer interface alias. [\#382](https://github.com/php-enqueue/enqueue-dev/pull/382) ([makasim](https://github.com/makasim)) + +## [0.8.21](https://github.com/php-enqueue/enqueue-dev/tree/0.8.21) (2018-02-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.20...0.8.21) + +**Merged pull requests:** + +- \[symfony\] Print command name [\#374](https://github.com/php-enqueue/enqueue-dev/pull/374) ([makasim](https://github.com/makasim)) + +## [0.8.20](https://github.com/php-enqueue/enqueue-dev/tree/0.8.20) (2018-02-15) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.19...0.8.20) + +**Merged pull requests:** + +- \[Redis\] Add ability to pass Redis instance to connection factory [\#372](https://github.com/php-enqueue/enqueue-dev/pull/372) ([makasim](https://github.com/makasim)) + +## [0.8.19](https://github.com/php-enqueue/enqueue-dev/tree/0.8.19) (2018-02-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.18...0.8.19) + +**Merged pull requests:** + +- \[dbal\] Sort priority messages by published at date too. [\#371](https://github.com/php-enqueue/enqueue-dev/pull/371) ([makasim](https://github.com/makasim)) +- Fix typo [\#369](https://github.com/php-enqueue/enqueue-dev/pull/369) ([kubk](https://github.com/kubk)) +- \[client\]\[skip ci\] Explain meaning of sendEvent, sendCommand methods. [\#365](https://github.com/php-enqueue/enqueue-dev/pull/365) ([makasim](https://github.com/makasim)) +- Modify async\_events.md grammar [\#364](https://github.com/php-enqueue/enqueue-dev/pull/364) ([ddproxy](https://github.com/ddproxy)) +- Fix wrong argument type [\#361](https://github.com/php-enqueue/enqueue-dev/pull/361) ([olix21](https://github.com/olix21)) + +## [0.8.18](https://github.com/php-enqueue/enqueue-dev/tree/0.8.18) (2018-02-07) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.17...0.8.18) + +**Merged pull requests:** + +- \[bundle\] DefaultTransportFactory should accept DSN like foo: [\#358](https://github.com/php-enqueue/enqueue-dev/pull/358) ([makasim](https://github.com/makasim)) +- Added endpoint configuration and updated the tests [\#353](https://github.com/php-enqueue/enqueue-dev/pull/353) ([gitis](https://github.com/gitis)) +- Moved symfony/framework-bundle to require-dev [\#348](https://github.com/php-enqueue/enqueue-dev/pull/348) ([prisis](https://github.com/prisis)) +- Gearman PHP 7 support [\#347](https://github.com/php-enqueue/enqueue-dev/pull/347) ([Jawshua](https://github.com/Jawshua)) +- \[dbal\] Consumer never fetches messages ordered by published time [\#343](https://github.com/php-enqueue/enqueue-dev/pull/343) ([f7h](https://github.com/f7h)) + +## [0.8.17](https://github.com/php-enqueue/enqueue-dev/tree/0.8.17) (2018-01-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.16...0.8.17) + +**Merged pull requests:** + +- \[consumption\] Prepare QueueConsumer for changes in 0.9 [\#337](https://github.com/php-enqueue/enqueue-dev/pull/337) ([makasim](https://github.com/makasim)) +- \[consumption\] Make QueueConsumer final [\#336](https://github.com/php-enqueue/enqueue-dev/pull/336) ([makasim](https://github.com/makasim)) +- \[bundle\]\[dx\] Add a message that suggest installing a pkg to use the transport. [\#335](https://github.com/php-enqueue/enqueue-dev/pull/335) ([makasim](https://github.com/makasim)) +- \[0.9\]\[BC break\]\[dbal\] Store UUIDs as binary data. Improves performance [\#280](https://github.com/php-enqueue/enqueue-dev/pull/280) ([makasim](https://github.com/makasim)) + +## [0.8.16](https://github.com/php-enqueue/enqueue-dev/tree/0.8.16) (2018-01-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.15...0.8.16) + +**Merged pull requests:** + +- \[Sqs\] Allow array-based DSN configuration [\#315](https://github.com/php-enqueue/enqueue-dev/pull/315) ([beryllium](https://github.com/beryllium)) + +## [0.8.15](https://github.com/php-enqueue/enqueue-dev/tree/0.8.15) (2018-01-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.14...0.8.15) + +**Merged pull requests:** + +- \[amqp\] fix signal handler if consume called from consume [\#328](https://github.com/php-enqueue/enqueue-dev/pull/328) ([makasim](https://github.com/makasim)) +- Update config\_reference.md [\#326](https://github.com/php-enqueue/enqueue-dev/pull/326) ([errogaht](https://github.com/errogaht)) +- Update message\_producer.md [\#325](https://github.com/php-enqueue/enqueue-dev/pull/325) ([errogaht](https://github.com/errogaht)) +- Update consumption\_extension.md [\#324](https://github.com/php-enqueue/enqueue-dev/pull/324) ([errogaht](https://github.com/errogaht)) +- \[consumption\] Correct message in LoggerExtension [\#322](https://github.com/php-enqueue/enqueue-dev/pull/322) ([makasim](https://github.com/makasim)) + +## [0.8.14](https://github.com/php-enqueue/enqueue-dev/tree/0.8.14) (2018-01-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.13...0.8.14) + +## [0.8.13](https://github.com/php-enqueue/enqueue-dev/tree/0.8.13) (2018-01-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.12...0.8.13) + +**Merged pull requests:** + +- \[amqp\] Fix socket and signal issue. [\#317](https://github.com/php-enqueue/enqueue-dev/pull/317) ([makasim](https://github.com/makasim)) +- \[kafka\] add ability to set offset. [\#314](https://github.com/php-enqueue/enqueue-dev/pull/314) ([makasim](https://github.com/makasim)) + +## [0.8.12](https://github.com/php-enqueue/enqueue-dev/tree/0.8.12) (2018-01-04) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.11...0.8.12) + +**Merged pull requests:** + +- \[rdkafka\] Don't do unnecessary subscribe\unsubscribe on every receive call [\#313](https://github.com/php-enqueue/enqueue-dev/pull/313) ([makasim](https://github.com/makasim)) +- \[consumption\] Fix signal handling when AMQP is used. [\#310](https://github.com/php-enqueue/enqueue-dev/pull/310) ([makasim](https://github.com/makasim)) +- Using Laravel helper to resolve filepath [\#302](https://github.com/php-enqueue/enqueue-dev/pull/302) ([robinvdvleuten](https://github.com/robinvdvleuten)) +- Changed larvel to laravel [\#301](https://github.com/php-enqueue/enqueue-dev/pull/301) ([robinvdvleuten](https://github.com/robinvdvleuten)) +- Check if logger exists [\#299](https://github.com/php-enqueue/enqueue-dev/pull/299) ([pascaldevink](https://github.com/pascaldevink)) +- Fix reversed logic for native UUID detection [\#297](https://github.com/php-enqueue/enqueue-dev/pull/297) ([msheakoski](https://github.com/msheakoski)) +- Job queue create tables [\#293](https://github.com/php-enqueue/enqueue-dev/pull/293) ([makasim](https://github.com/makasim)) + +## [0.8.11](https://github.com/php-enqueue/enqueue-dev/tree/0.8.11) (2017-12-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.10...0.8.11) + +**Merged pull requests:** + +- \[job-queue\] Change typehint, allow not only Closure but other callabl… [\#292](https://github.com/php-enqueue/enqueue-dev/pull/292) ([makasim](https://github.com/makasim)) +- \[dbal\] Fix message re-queuing. Reuse producer for it. [\#291](https://github.com/php-enqueue/enqueue-dev/pull/291) ([makasim](https://github.com/makasim)) +- \[consumption\] Add ability to overwrite logger. [\#289](https://github.com/php-enqueue/enqueue-dev/pull/289) ([makasim](https://github.com/makasim)) +- \[doc\] yii2-queue amqp driver [\#282](https://github.com/php-enqueue/enqueue-dev/pull/282) ([makasim](https://github.com/makasim)) + +## [0.8.10](https://github.com/php-enqueue/enqueue-dev/tree/0.8.10) (2017-12-04) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.9...0.8.10) + +**Merged pull requests:** + +- \[doc\]\[skip ci\] add doc for client on send extensions. [\#285](https://github.com/php-enqueue/enqueue-dev/pull/285) ([makasim](https://github.com/makasim)) +- \[doc\]\[skip ci\] Add processor examples, notes on exception and more. [\#283](https://github.com/php-enqueue/enqueue-dev/pull/283) ([makasim](https://github.com/makasim)) +- \[travis\] add PHP 7.2 to build matrix. [\#281](https://github.com/php-enqueue/enqueue-dev/pull/281) ([makasim](https://github.com/makasim)) + +## [0.8.9](https://github.com/php-enqueue/enqueue-dev/tree/0.8.9) (2017-11-21) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.8...0.8.9) + +**Merged pull requests:** + +- \[docker\] Incorporate amqp ext compilation to docker build process. [\#275](https://github.com/php-enqueue/enqueue-dev/pull/275) ([makasim](https://github.com/makasim)) +- \[bundle\] Apparently the use case tests have never worked properly. [\#273](https://github.com/php-enqueue/enqueue-dev/pull/273) ([makasim](https://github.com/makasim)) +- \[fs\] Copy past Symfony's LockHandler \(not awailable in Sf4\). [\#272](https://github.com/php-enqueue/enqueue-dev/pull/272) ([makasim](https://github.com/makasim)) +- Add Symfony4 support [\#269](https://github.com/php-enqueue/enqueue-dev/pull/269) ([makasim](https://github.com/makasim)) +- \[bundle\] use enqueue logo in profiler panel. [\#268](https://github.com/php-enqueue/enqueue-dev/pull/268) ([makasim](https://github.com/makasim)) +- \[rdkafka\] do not pass config if it was not set explisitly. [\#263](https://github.com/php-enqueue/enqueue-dev/pull/263) ([makasim](https://github.com/makasim)) + +## [0.8.8](https://github.com/php-enqueue/enqueue-dev/tree/0.8.8) (2017-11-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.7...0.8.8) + +**Merged pull requests:** + +- \[Redis\] add dsn support for symfony bundle. [\#266](https://github.com/php-enqueue/enqueue-dev/pull/266) ([wilson-ng](https://github.com/wilson-ng)) +- \[consumption\]\[amqp\] onIdle is never called. [\#265](https://github.com/php-enqueue/enqueue-dev/pull/265) ([makasim](https://github.com/makasim)) +- \[consumption\] fix context is missing message on exception. [\#264](https://github.com/php-enqueue/enqueue-dev/pull/264) ([makasim](https://github.com/makasim)) + +## [0.8.7](https://github.com/php-enqueue/enqueue-dev/tree/0.8.7) (2017-11-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.6...0.8.7) + +**Merged pull requests:** + +- Changes SetRouterPropertiesExtension to use the driver to generate the queue name [\#262](https://github.com/php-enqueue/enqueue-dev/pull/262) ([iainmckay](https://github.com/iainmckay)) +- \[Redis\] add custom database index [\#258](https://github.com/php-enqueue/enqueue-dev/pull/258) ([IndraGunawan](https://github.com/IndraGunawan)) + +## [0.8.6](https://github.com/php-enqueue/enqueue-dev/tree/0.8.6) (2017-11-05) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.5...0.8.6) + +**Merged pull requests:** + +- \[RdKafka\] Enable serializers to serialize message keys [\#254](https://github.com/php-enqueue/enqueue-dev/pull/254) ([tPl0ch](https://github.com/tPl0ch)) + +## [0.8.5](https://github.com/php-enqueue/enqueue-dev/tree/0.8.5) (2017-11-02) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.4...0.8.5) + +**Merged pull requests:** + +- Amqp add ssl pass phrase option [\#249](https://github.com/php-enqueue/enqueue-dev/pull/249) ([makasim](https://github.com/makasim)) +- \[amqp-lib\] Ignore empty ssl options. [\#248](https://github.com/php-enqueue/enqueue-dev/pull/248) ([makasim](https://github.com/makasim)) + +## [0.8.4](https://github.com/php-enqueue/enqueue-dev/tree/0.8.4) (2017-11-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.3...0.8.4) + +## [0.8.3](https://github.com/php-enqueue/enqueue-dev/tree/0.8.3) (2017-11-01) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.2...0.8.3) + +**Merged pull requests:** + +- \[bundle\] streamline profiler view when no messages were sent [\#247](https://github.com/php-enqueue/enqueue-dev/pull/247) ([dkarlovi](https://github.com/dkarlovi)) +- \[bundle\] Renamed exposed services' name to classes' FQCN [\#242](https://github.com/php-enqueue/enqueue-dev/pull/242) ([Lctrs](https://github.com/Lctrs)) + +## [0.8.2](https://github.com/php-enqueue/enqueue-dev/tree/0.8.2) (2017-10-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.1...0.8.2) + +**Merged pull requests:** + +- \[amqp\] Add AMQP secure \(SSL\) connections support [\#246](https://github.com/php-enqueue/enqueue-dev/pull/246) ([makasim](https://github.com/makasim)) + +## [0.8.1](https://github.com/php-enqueue/enqueue-dev/tree/0.8.1) (2017-10-23) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.8.0...0.8.1) + +**Merged pull requests:** + +- Only add Ampq transport factories when packages are found [\#241](https://github.com/php-enqueue/enqueue-dev/pull/241) ([jverdeyen](https://github.com/jverdeyen)) +- GPS Integration [\#239](https://github.com/php-enqueue/enqueue-dev/pull/239) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.8.0](https://github.com/php-enqueue/enqueue-dev/tree/0.8.0) (2017-10-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.19...0.8.0) + +**Merged pull requests:** + +- 0.8v goes stable. [\#238](https://github.com/php-enqueue/enqueue-dev/pull/238) ([makasim](https://github.com/makasim)) +- \[travis\] allow kafka tests to fail. [\#237](https://github.com/php-enqueue/enqueue-dev/pull/237) ([makasim](https://github.com/makasim)) +- \[consumption\]\[amqp\] move beforeReceive call at the end of the cycle f… [\#234](https://github.com/php-enqueue/enqueue-dev/pull/234) ([makasim](https://github.com/makasim)) +- \[amqp\] One single transport factory for all supported amqp implementa… [\#233](https://github.com/php-enqueue/enqueue-dev/pull/233) ([makasim](https://github.com/makasim)) +- Missing client configuration in the documentation [\#231](https://github.com/php-enqueue/enqueue-dev/pull/231) ([lsv](https://github.com/lsv)) +- Added MIT license badge [\#230](https://github.com/php-enqueue/enqueue-dev/pull/230) ([tarlepp](https://github.com/tarlepp)) +- \[BC break\]\[amqp\] Introduce connection config. Make it same across all transports. [\#228](https://github.com/php-enqueue/enqueue-dev/pull/228) ([makasim](https://github.com/makasim)) + +## [0.7.19](https://github.com/php-enqueue/enqueue-dev/tree/0.7.19) (2017-10-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.18...0.7.19) + +**Merged pull requests:** + +- Fix typo [\#227](https://github.com/php-enqueue/enqueue-dev/pull/227) ([f3ath](https://github.com/f3ath)) +- Amqp basic consume fixes [\#223](https://github.com/php-enqueue/enqueue-dev/pull/223) ([makasim](https://github.com/makasim)) +- Adds to small extension points to JobProcessor [\#222](https://github.com/php-enqueue/enqueue-dev/pull/222) ([iainmckay](https://github.com/iainmckay)) +- \[BC break\]\[amqp\] Use same qos options across all all AMQP transports [\#221](https://github.com/php-enqueue/enqueue-dev/pull/221) ([makasim](https://github.com/makasim)) +- \[BC break\] Amqp add basic consume support [\#217](https://github.com/php-enqueue/enqueue-dev/pull/217) ([makasim](https://github.com/makasim)) + +## [0.7.18](https://github.com/php-enqueue/enqueue-dev/tree/0.7.18) (2017-10-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.17...0.7.18) + +**Merged pull requests:** + +- \[client\] Add --skip option to consume command. [\#218](https://github.com/php-enqueue/enqueue-dev/pull/218) ([makasim](https://github.com/makasim)) + +## [0.7.17](https://github.com/php-enqueue/enqueue-dev/tree/0.7.17) (2017-10-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.16...0.7.17) + +**Merged pull requests:** + +- Fs do not throw error on user deprecate [\#214](https://github.com/php-enqueue/enqueue-dev/pull/214) ([makasim](https://github.com/makasim)) +- \[bundle\]\[profiler\] Fix array to string conversion notice. [\#212](https://github.com/php-enqueue/enqueue-dev/pull/212) ([makasim](https://github.com/makasim)) + +## [0.7.16](https://github.com/php-enqueue/enqueue-dev/tree/0.7.16) (2017-09-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.15...0.7.16) + +**Merged pull requests:** + +- Fixes the notation for Twig template names in the data collector [\#207](https://github.com/php-enqueue/enqueue-dev/pull/207) ([Lctrs](https://github.com/Lctrs)) +- \[BC Break\]\[dsn\] replace xxx:// to xxx: [\#205](https://github.com/php-enqueue/enqueue-dev/pull/205) ([makasim](https://github.com/makasim)) + +## [0.7.15](https://github.com/php-enqueue/enqueue-dev/tree/0.7.15) (2017-09-25) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.14...0.7.15) + +**Merged pull requests:** + +- \[redis\] add dsn support for redis transport. [\#204](https://github.com/php-enqueue/enqueue-dev/pull/204) ([makasim](https://github.com/makasim)) +- \[fs\] fix bugs introduced in \#181. [\#203](https://github.com/php-enqueue/enqueue-dev/pull/203) ([makasim](https://github.com/makasim)) +- \[dbal\]\[bc break\] Performance improvements and new features. [\#199](https://github.com/php-enqueue/enqueue-dev/pull/199) ([makasim](https://github.com/makasim)) + +## [0.7.14](https://github.com/php-enqueue/enqueue-dev/tree/0.7.14) (2017-09-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.13...0.7.14) + +## [0.7.13](https://github.com/php-enqueue/enqueue-dev/tree/0.7.13) (2017-09-13) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.12...0.7.13) + +**Merged pull requests:** + +- \[dbal\] add priority support on transport level. [\#198](https://github.com/php-enqueue/enqueue-dev/pull/198) ([makasim](https://github.com/makasim)) +- \[bundle\] add tests for the case where topic subscriber does not def p… [\#197](https://github.com/php-enqueue/enqueue-dev/pull/197) ([makasim](https://github.com/makasim)) +- Fixed losing message priority for dbal driver [\#195](https://github.com/php-enqueue/enqueue-dev/pull/195) ([vtsykun](https://github.com/vtsykun)) + +## [0.7.12](https://github.com/php-enqueue/enqueue-dev/tree/0.7.12) (2017-09-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.11...0.7.12) + +**Merged pull requests:** + +- fixed NS [\#194](https://github.com/php-enqueue/enqueue-dev/pull/194) ([chdeliens](https://github.com/chdeliens)) + +## [0.7.11](https://github.com/php-enqueue/enqueue-dev/tree/0.7.11) (2017-09-11) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.10...0.7.11) + +**Merged pull requests:** + +- Queue Consumer Options [\#193](https://github.com/php-enqueue/enqueue-dev/pull/193) ([ASKozienko](https://github.com/ASKozienko)) +- \[FS\] Polling Interval [\#192](https://github.com/php-enqueue/enqueue-dev/pull/192) ([ASKozienko](https://github.com/ASKozienko)) +- \[Symfony\] added toolbar info in profiler [\#190](https://github.com/php-enqueue/enqueue-dev/pull/190) ([Miliooo](https://github.com/Miliooo)) +- docs cli\_commands.md fix [\#189](https://github.com/php-enqueue/enqueue-dev/pull/189) ([Miliooo](https://github.com/Miliooo)) + +## [0.7.10](https://github.com/php-enqueue/enqueue-dev/tree/0.7.10) (2017-08-31) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.9...0.7.10) + +**Merged pull requests:** + +- \[rdkafka\] Add abilito change the way a message is serialized. [\#188](https://github.com/php-enqueue/enqueue-dev/pull/188) ([makasim](https://github.com/makasim)) + +## [0.7.9](https://github.com/php-enqueue/enqueue-dev/tree/0.7.9) (2017-08-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.8...0.7.9) + +**Merged pull requests:** + +- \[client\] DelayRedeliveredMessageExtension. Add reject reason. [\#185](https://github.com/php-enqueue/enqueue-dev/pull/185) ([makasim](https://github.com/makasim)) +- \[phpstan\] update to 0.8 version [\#184](https://github.com/php-enqueue/enqueue-dev/pull/184) ([makasim](https://github.com/makasim)) + +## [0.7.8](https://github.com/php-enqueue/enqueue-dev/tree/0.7.8) (2017-08-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.7...0.7.8) + +**Merged pull requests:** + +- \[consumption\] Do not close context. [\#183](https://github.com/php-enqueue/enqueue-dev/pull/183) ([makasim](https://github.com/makasim)) +- \[bundle\] do not use client's related stuff if it is disabled [\#182](https://github.com/php-enqueue/enqueue-dev/pull/182) ([makasim](https://github.com/makasim)) +- \[fs\] fix bug that happens with specific message length. [\#181](https://github.com/php-enqueue/enqueue-dev/pull/181) ([makasim](https://github.com/makasim)) +- \[sqs\] Skip tests if no amazon credentinals present. [\#180](https://github.com/php-enqueue/enqueue-dev/pull/180) ([makasim](https://github.com/makasim)) +- Fix typo in configuration parameter [\#178](https://github.com/php-enqueue/enqueue-dev/pull/178) ([akucherenko](https://github.com/akucherenko)) +- Google Pub/Sub [\#167](https://github.com/php-enqueue/enqueue-dev/pull/167) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.7.7](https://github.com/php-enqueue/enqueue-dev/tree/0.7.7) (2017-08-25) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.6...0.7.7) + +**Merged pull requests:** + +- Use Query Builder for better support across platforms. [\#176](https://github.com/php-enqueue/enqueue-dev/pull/176) ([jenkoian](https://github.com/jenkoian)) +- fix pheanstalk redelivered, receive [\#173](https://github.com/php-enqueue/enqueue-dev/pull/173) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.7.6](https://github.com/php-enqueue/enqueue-dev/tree/0.7.6) (2017-08-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.5...0.7.6) + +## [0.7.5](https://github.com/php-enqueue/enqueue-dev/tree/0.7.5) (2017-08-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.4...0.7.5) + +**Merged pull requests:** + +- Bundle disable async events by default [\#169](https://github.com/php-enqueue/enqueue-dev/pull/169) ([makasim](https://github.com/makasim)) +- Delay Strategy Configuration [\#162](https://github.com/php-enqueue/enqueue-dev/pull/162) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.7.4](https://github.com/php-enqueue/enqueue-dev/tree/0.7.4) (2017-08-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.3...0.7.4) + +## [0.7.3](https://github.com/php-enqueue/enqueue-dev/tree/0.7.3) (2017-08-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.2...0.7.3) + +## [0.7.2](https://github.com/php-enqueue/enqueue-dev/tree/0.7.2) (2017-08-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.1...0.7.2) + +**Merged pull requests:** + +- \[consumption\] adjust receive and idle timeouts [\#165](https://github.com/php-enqueue/enqueue-dev/pull/165) ([makasim](https://github.com/makasim)) +- Remove maxDepth option on profiler dump. [\#164](https://github.com/php-enqueue/enqueue-dev/pull/164) ([jenkoian](https://github.com/jenkoian)) + +## [0.7.1](https://github.com/php-enqueue/enqueue-dev/tree/0.7.1) (2017-08-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.7.0...0.7.1) + +**Merged pull requests:** + +- Client fix command routing [\#163](https://github.com/php-enqueue/enqueue-dev/pull/163) ([makasim](https://github.com/makasim)) + +## [0.7.0](https://github.com/php-enqueue/enqueue-dev/tree/0.7.0) (2017-08-07) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.6.2...0.7.0) + +**Merged pull requests:** + +- continue if exclusive is set to false [\#156](https://github.com/php-enqueue/enqueue-dev/pull/156) ([toooni](https://github.com/toooni)) +- \[doc\] add elastica populate bundle [\#155](https://github.com/php-enqueue/enqueue-dev/pull/155) ([makasim](https://github.com/makasim)) +- \[producer\] do not throw exception if feature not implemented and null… [\#154](https://github.com/php-enqueue/enqueue-dev/pull/154) ([makasim](https://github.com/makasim)) +- Amqp bunny [\#153](https://github.com/php-enqueue/enqueue-dev/pull/153) ([makasim](https://github.com/makasim)) +- \[amqp\] Delay Strategy [\#152](https://github.com/php-enqueue/enqueue-dev/pull/152) ([ASKozienko](https://github.com/ASKozienko)) +- \[client\] Use default as router topic. [\#151](https://github.com/php-enqueue/enqueue-dev/pull/151) ([makasim](https://github.com/makasim)) +- Amqp Tutorial [\#150](https://github.com/php-enqueue/enqueue-dev/pull/150) ([ASKozienko](https://github.com/ASKozienko)) +- Delay, ttl, priority, in producer [\#149](https://github.com/php-enqueue/enqueue-dev/pull/149) ([makasim](https://github.com/makasim)) +- \[Amqp\] Qos [\#148](https://github.com/php-enqueue/enqueue-dev/pull/148) ([ASKozienko](https://github.com/ASKozienko)) +- amqp interop client [\#144](https://github.com/php-enqueue/enqueue-dev/pull/144) ([ASKozienko](https://github.com/ASKozienko)) +- \[composer\] Add extensions to platform config. [\#139](https://github.com/php-enqueue/enqueue-dev/pull/139) ([makasim](https://github.com/makasim)) +- Amqp Interop [\#138](https://github.com/php-enqueue/enqueue-dev/pull/138) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.6.2](https://github.com/php-enqueue/enqueue-dev/tree/0.6.2) (2017-07-21) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.6.1...0.6.2) + +**Merged pull requests:** + +- Laravel queue package [\#137](https://github.com/php-enqueue/enqueue-dev/pull/137) ([makasim](https://github.com/makasim)) +- Add AmqpLib support [\#136](https://github.com/php-enqueue/enqueue-dev/pull/136) ([fibula](https://github.com/fibula)) + +## [0.6.1](https://github.com/php-enqueue/enqueue-dev/tree/0.6.1) (2017-07-17) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.6.0...0.6.1) + +**Merged pull requests:** + +- RdKafka Transport [\#134](https://github.com/php-enqueue/enqueue-dev/pull/134) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.6.0](https://github.com/php-enqueue/enqueue-dev/tree/0.6.0) (2017-07-07) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.5.3...0.6.0) + +**Merged pull requests:** + +- Remove previously deprecated code. [\#131](https://github.com/php-enqueue/enqueue-dev/pull/131) ([makasim](https://github.com/makasim)) +- Migrate to queue interop [\#130](https://github.com/php-enqueue/enqueue-dev/pull/130) ([makasim](https://github.com/makasim)) + +## [0.5.3](https://github.com/php-enqueue/enqueue-dev/tree/0.5.3) (2017-07-06) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.5.2...0.5.3) + +**Merged pull requests:** + +- \[bundle\] Extend EventDispatcher instead of container aware one. [\#129](https://github.com/php-enqueue/enqueue-dev/pull/129) ([makasim](https://github.com/makasim)) + +## [0.5.2](https://github.com/php-enqueue/enqueue-dev/tree/0.5.2) (2017-07-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.5.1...0.5.2) + +**Merged pull requests:** + +- \[client\] Send exclusive commands to their queues directly, by passing… [\#127](https://github.com/php-enqueue/enqueue-dev/pull/127) ([makasim](https://github.com/makasim)) +- \[symfony\] Extract DriverFactoryInterface from TransportFactoryInterface. [\#126](https://github.com/php-enqueue/enqueue-dev/pull/126) ([makasim](https://github.com/makasim)) + +## [0.5.1](https://github.com/php-enqueue/enqueue-dev/tree/0.5.1) (2017-06-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.5.0...0.5.1) + +**Merged pull requests:** + +- Add Gearman transport. [\#125](https://github.com/php-enqueue/enqueue-dev/pull/125) ([makasim](https://github.com/makasim)) + +## [0.5.0](https://github.com/php-enqueue/enqueue-dev/tree/0.5.0) (2017-06-26) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.20...0.5.0) + +**Merged pull requests:** + +- \[client\] Merge experimental ProducerV2 methods to Producer interface. [\#124](https://github.com/php-enqueue/enqueue-dev/pull/124) ([makasim](https://github.com/makasim)) +- \[WIP\]\[beanstalk\] Add transport for beanstalkd [\#123](https://github.com/php-enqueue/enqueue-dev/pull/123) ([makasim](https://github.com/makasim)) +- fix dbal polling interval configuration option [\#122](https://github.com/php-enqueue/enqueue-dev/pull/122) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.4.20](https://github.com/php-enqueue/enqueue-dev/tree/0.4.20) (2017-06-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.19...0.4.20) + +## [0.4.19](https://github.com/php-enqueue/enqueue-dev/tree/0.4.19) (2017-06-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.18...0.4.19) + +## [0.4.18](https://github.com/php-enqueue/enqueue-dev/tree/0.4.18) (2017-06-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.17...0.4.18) + +**Merged pull requests:** + +- \[client\] Add ability to define a command as exclusive [\#120](https://github.com/php-enqueue/enqueue-dev/pull/120) ([makasim](https://github.com/makasim)) + +## [0.4.17](https://github.com/php-enqueue/enqueue-dev/tree/0.4.17) (2017-06-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.16...0.4.17) + +**Merged pull requests:** + +- \[simple-client\] Allow processor instance bind. [\#119](https://github.com/php-enqueue/enqueue-dev/pull/119) ([makasim](https://github.com/makasim)) +- \[amqp\] Add 'receive\_method' to amqp transport factory. [\#118](https://github.com/php-enqueue/enqueue-dev/pull/118) ([makasim](https://github.com/makasim)) +- \[amqp\] Fixes high CPU consumption when basic get is used [\#117](https://github.com/php-enqueue/enqueue-dev/pull/117) ([makasim](https://github.com/makasim)) + +## [0.4.16](https://github.com/php-enqueue/enqueue-dev/tree/0.4.16) (2017-06-16) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.15...0.4.16) + +**Merged pull requests:** + +- ProducerV2 For SimpleClient [\#115](https://github.com/php-enqueue/enqueue-dev/pull/115) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.4.15](https://github.com/php-enqueue/enqueue-dev/tree/0.4.15) (2017-06-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.14...0.4.15) + +**Merged pull requests:** + +- RPC Deletes Reply Queue After Receive Message [\#114](https://github.com/php-enqueue/enqueue-dev/pull/114) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.4.14](https://github.com/php-enqueue/enqueue-dev/tree/0.4.14) (2017-06-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.13...0.4.14) + +**Merged pull requests:** + +- \[RFC\]\[client\] Add ability to send events or commands. [\#113](https://github.com/php-enqueue/enqueue-dev/pull/113) ([makasim](https://github.com/makasim)) + +## [0.4.13](https://github.com/php-enqueue/enqueue-dev/tree/0.4.13) (2017-06-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.12...0.4.13) + +**Merged pull requests:** + +- \[amqp\] Add ability to choose what receive method to use: basic\_get or basic\_consume. [\#112](https://github.com/php-enqueue/enqueue-dev/pull/112) ([makasim](https://github.com/makasim)) + +## [0.4.12](https://github.com/php-enqueue/enqueue-dev/tree/0.4.12) (2017-06-08) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.11...0.4.12) + +**Merged pull requests:** + +- \[amqp\]\[hotfix\] Switch to AMQP' basic.get till the issue with basic.consume is solved. [\#111](https://github.com/php-enqueue/enqueue-dev/pull/111) ([makasim](https://github.com/makasim)) +- \[amqp\] Add pre\_fetch\_count, pre\_fetch\_size options. [\#108](https://github.com/php-enqueue/enqueue-dev/pull/108) ([makasim](https://github.com/makasim)) + +## [0.4.11](https://github.com/php-enqueue/enqueue-dev/tree/0.4.11) (2017-05-30) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.10...0.4.11) + +**Merged pull requests:** + +- \[bundle\] Fix "Incompatible use of dynamic environment variables "ENQUEUE\_DSN" found in parameters." [\#107](https://github.com/php-enqueue/enqueue-dev/pull/107) ([makasim](https://github.com/makasim)) + +## [0.4.10](https://github.com/php-enqueue/enqueue-dev/tree/0.4.10) (2017-05-26) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.9...0.4.10) + +**Merged pull requests:** + +- \[dbal\] Add DSN support. [\#104](https://github.com/php-enqueue/enqueue-dev/pull/104) ([makasim](https://github.com/makasim)) +- Calling AmqpContext::declareQueue\(\) now returns an integer holding the queue message count [\#66](https://github.com/php-enqueue/enqueue-dev/pull/66) ([J7mbo](https://github.com/J7mbo)) + +## [0.4.9](https://github.com/php-enqueue/enqueue-dev/tree/0.4.9) (2017-05-25) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.8...0.4.9) + +**Merged pull requests:** + +- \[transport\] Fs transport dsn must contain one extra "/" [\#103](https://github.com/php-enqueue/enqueue-dev/pull/103) ([makasim](https://github.com/makasim)) +- Add message spec test case [\#102](https://github.com/php-enqueue/enqueue-dev/pull/102) ([makasim](https://github.com/makasim)) + +## [0.4.8](https://github.com/php-enqueue/enqueue-dev/tree/0.4.8) (2017-05-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.6...0.4.8) + +**Merged pull requests:** + +- \[client\] Fixes edge cases in client's routing logic. [\#101](https://github.com/php-enqueue/enqueue-dev/pull/101) ([makasim](https://github.com/makasim)) +- \[bundle\] Auto register reply extension. [\#100](https://github.com/php-enqueue/enqueue-dev/pull/100) ([makasim](https://github.com/makasim)) +- Do pkg release if there are changes in it. [\#98](https://github.com/php-enqueue/enqueue-dev/pull/98) ([makasim](https://github.com/makasim)) + +## [0.4.6](https://github.com/php-enqueue/enqueue-dev/tree/0.4.6) (2017-05-23) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.5...0.4.6) + +## [0.4.5](https://github.com/php-enqueue/enqueue-dev/tree/0.4.5) (2017-05-22) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.4...0.4.5) + +**Merged pull requests:** + +- Symfony. Async event subscriber. [\#95](https://github.com/php-enqueue/enqueue-dev/pull/95) ([makasim](https://github.com/makasim)) + +## [0.4.4](https://github.com/php-enqueue/enqueue-dev/tree/0.4.4) (2017-05-20) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.3...0.4.4) + +**Merged pull requests:** + +- Symfony. Async event dispatching [\#86](https://github.com/php-enqueue/enqueue-dev/pull/86) ([makasim](https://github.com/makasim)) + +## [0.4.3](https://github.com/php-enqueue/enqueue-dev/tree/0.4.3) (2017-05-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.2...0.4.3) + +**Merged pull requests:** + +- \[client\] SpoolProducer [\#93](https://github.com/php-enqueue/enqueue-dev/pull/93) ([makasim](https://github.com/makasim)) +- Add some handy functions. Improve READMEs [\#92](https://github.com/php-enqueue/enqueue-dev/pull/92) ([makasim](https://github.com/makasim)) +- Run phpstan and php-cs-fixer on travis [\#85](https://github.com/php-enqueue/enqueue-dev/pull/85) ([makasim](https://github.com/makasim)) + +## [0.4.2](https://github.com/php-enqueue/enqueue-dev/tree/0.4.2) (2017-05-15) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.1...0.4.2) + +**Merged pull requests:** + +- Add dsn\_to\_connection\_factory and dsn\_to\_context functions. [\#84](https://github.com/php-enqueue/enqueue-dev/pull/84) ([makasim](https://github.com/makasim)) +- Add ability to set transport DSN directly to default transport factory. [\#81](https://github.com/php-enqueue/enqueue-dev/pull/81) ([makasim](https://github.com/makasim)) +- \[bundle\] Set null transport as default. Prevent errors on bundle install. [\#77](https://github.com/php-enqueue/enqueue-dev/pull/77) ([makasim](https://github.com/makasim)) + +## [0.4.1](https://github.com/php-enqueue/enqueue-dev/tree/0.4.1) (2017-05-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.4.0...0.4.1) + +## [0.4.0](https://github.com/php-enqueue/enqueue-dev/tree/0.4.0) (2017-05-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.8...0.4.0) + +**Merged pull requests:** + +- \[fs\] add DSN support [\#82](https://github.com/php-enqueue/enqueue-dev/pull/82) ([makasim](https://github.com/makasim)) +- \[amqp\] Configure by string DSN. [\#80](https://github.com/php-enqueue/enqueue-dev/pull/80) ([makasim](https://github.com/makasim)) +- \[fs\] Filesystem transport must create a storage dir if it does not exists. [\#78](https://github.com/php-enqueue/enqueue-dev/pull/78) ([makasim](https://github.com/makasim)) +- \[magento\] Add basic docs for enqueue magento extension. [\#76](https://github.com/php-enqueue/enqueue-dev/pull/76) ([makasim](https://github.com/makasim)) + +## [0.3.8](https://github.com/php-enqueue/enqueue-dev/tree/0.3.8) (2017-05-10) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.7...0.3.8) + +**Merged pull requests:** + +- Multi Transport Simple Client [\#75](https://github.com/php-enqueue/enqueue-dev/pull/75) ([ASKozienko](https://github.com/ASKozienko)) +- Client Extensions [\#72](https://github.com/php-enqueue/enqueue-dev/pull/72) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.3.7](https://github.com/php-enqueue/enqueue-dev/tree/0.3.7) (2017-05-04) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.6...0.3.7) + +**Merged pull requests:** + +- JobQueue/Job shouldn't be required when Doctrine schema update [\#71](https://github.com/php-enqueue/enqueue-dev/pull/71) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.3.6](https://github.com/php-enqueue/enqueue-dev/tree/0.3.6) (2017-04-28) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.5...0.3.6) + +**Merged pull requests:** + +- Amazon SQS Transport [\#60](https://github.com/php-enqueue/enqueue-dev/pull/60) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.3.5](https://github.com/php-enqueue/enqueue-dev/tree/0.3.5) (2017-04-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.4...0.3.5) + +**Merged pull requests:** + +- \[consumption\] Add support of QueueSubscriberInterface to transport consume command. [\#63](https://github.com/php-enqueue/enqueue-dev/pull/63) ([makasim](https://github.com/makasim)) +- \[client\] Add ability to hardcode queue name. It is used as is and not adjusted or modified in any way [\#61](https://github.com/php-enqueue/enqueue-dev/pull/61) ([makasim](https://github.com/makasim)) + +## [0.3.4](https://github.com/php-enqueue/enqueue-dev/tree/0.3.4) (2017-04-24) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.3...0.3.4) + +**Merged pull requests:** + +- DBAL Transport [\#54](https://github.com/php-enqueue/enqueue-dev/pull/54) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.3.3](https://github.com/php-enqueue/enqueue-dev/tree/0.3.3) (2017-04-21) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.2...0.3.3) + +**Merged pull requests:** + +- \[client\] Redis driver [\#59](https://github.com/php-enqueue/enqueue-dev/pull/59) ([makasim](https://github.com/makasim)) +- Redis transport. [\#55](https://github.com/php-enqueue/enqueue-dev/pull/55) ([makasim](https://github.com/makasim)) + +## [0.3.2](https://github.com/php-enqueue/enqueue-dev/tree/0.3.2) (2017-04-19) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.1...0.3.2) + +**Merged pull requests:** + +- share simple client context [\#52](https://github.com/php-enqueue/enqueue-dev/pull/52) ([ASKozienko](https://github.com/ASKozienko)) + +## [0.3.1](https://github.com/php-enqueue/enqueue-dev/tree/0.3.1) (2017-04-12) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.3.0...0.3.1) + +**Merged pull requests:** + +- \[client\] Add RpcClient on client level. [\#50](https://github.com/php-enqueue/enqueue-dev/pull/50) ([makasim](https://github.com/makasim)) + +## [0.3.0](https://github.com/php-enqueue/enqueue-dev/tree/0.3.0) (2017-04-07) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.12...0.3.0) + +**Merged pull requests:** + +- Remove deprecated stuff [\#48](https://github.com/php-enqueue/enqueue-dev/pull/48) ([makasim](https://github.com/makasim)) + +## [0.2.12](https://github.com/php-enqueue/enqueue-dev/tree/0.2.12) (2017-04-07) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.11...0.2.12) + +**Merged pull requests:** + +- \[client\] Rename MessageProducer classes to Producer [\#47](https://github.com/php-enqueue/enqueue-dev/pull/47) ([makasim](https://github.com/makasim)) +- \[consumption\] Add onResult extension point. [\#46](https://github.com/php-enqueue/enqueue-dev/pull/46) ([makasim](https://github.com/makasim)) +- \[transport\] Add Psr prefix to transport interfaces. Deprecates old ones. [\#45](https://github.com/php-enqueue/enqueue-dev/pull/45) ([makasim](https://github.com/makasim)) + +## [0.2.11](https://github.com/php-enqueue/enqueue-dev/tree/0.2.11) (2017-04-05) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.10...0.2.11) + +**Merged pull requests:** + +- \[client\] Add ability to define scope of send message. [\#40](https://github.com/php-enqueue/enqueue-dev/pull/40) ([makasim](https://github.com/makasim)) + +## [0.2.10](https://github.com/php-enqueue/enqueue-dev/tree/0.2.10) (2017-04-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.9...0.2.10) + +## [0.2.9](https://github.com/php-enqueue/enqueue-dev/tree/0.2.9) (2017-04-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.8...0.2.9) + +**Merged pull requests:** + +- \[bundle\] Fix extensions priority ordering. Must be from high to low. [\#38](https://github.com/php-enqueue/enqueue-dev/pull/38) ([makasim](https://github.com/makasim)) + +## [0.2.8](https://github.com/php-enqueue/enqueue-dev/tree/0.2.8) (2017-04-03) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.7...0.2.8) + +**Merged pull requests:** + +- Improvements and fixes [\#37](https://github.com/php-enqueue/enqueue-dev/pull/37) ([makasim](https://github.com/makasim)) +- fix fsdriver router topic name [\#34](https://github.com/php-enqueue/enqueue-dev/pull/34) ([bendavies](https://github.com/bendavies)) +- run php-cs-fixer [\#33](https://github.com/php-enqueue/enqueue-dev/pull/33) ([bendavies](https://github.com/bendavies)) + +## [0.2.7](https://github.com/php-enqueue/enqueue-dev/tree/0.2.7) (2017-03-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.6...0.2.7) + +**Merged pull requests:** + +- \[client\] Allow send objects that implements \JsonSerializable interface. [\#30](https://github.com/php-enqueue/enqueue-dev/pull/30) ([makasim](https://github.com/makasim)) + +## [0.2.6](https://github.com/php-enqueue/enqueue-dev/tree/0.2.6) (2017-03-14) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.5...0.2.6) + +**Merged pull requests:** + +- Fix Simple Client [\#29](https://github.com/php-enqueue/enqueue-dev/pull/29) ([ASKozienko](https://github.com/ASKozienko)) +- Update quick\_tour.md add Bundle to AppKernel [\#26](https://github.com/php-enqueue/enqueue-dev/pull/26) ([jverdeyen](https://github.com/jverdeyen)) +- \[doc\] Add docs about message processors. [\#24](https://github.com/php-enqueue/enqueue-dev/pull/24) ([makasim](https://github.com/makasim)) +- Fix unclear sentences in docs [\#21](https://github.com/php-enqueue/enqueue-dev/pull/21) ([cirnatdan](https://github.com/cirnatdan)) + +## [0.2.5](https://github.com/php-enqueue/enqueue-dev/tree/0.2.5) (2017-01-27) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.4...0.2.5) + +**Merged pull requests:** + +- \[amqp\] Put in buffer not our message. Continue consumption. [\#22](https://github.com/php-enqueue/enqueue-dev/pull/22) ([makasim](https://github.com/makasim)) +- \[travis\] Run test with different Symfony versions. 2.8, 3.0 [\#19](https://github.com/php-enqueue/enqueue-dev/pull/19) ([makasim](https://github.com/makasim)) +- \[fs\] Add missing enqueue/psr-queue package to composer.json. [\#18](https://github.com/php-enqueue/enqueue-dev/pull/18) ([makasim](https://github.com/makasim)) + +## [0.2.4](https://github.com/php-enqueue/enqueue-dev/tree/0.2.4) (2017-01-18) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.3...0.2.4) + +**Merged pull requests:** + +- \[consumption\]\[bug\] Receive timeout is in milliseconds. Set it to 5000.… [\#14](https://github.com/php-enqueue/enqueue-dev/pull/14) ([makasim](https://github.com/makasim)) +- Filesystem transport [\#12](https://github.com/php-enqueue/enqueue-dev/pull/12) ([makasim](https://github.com/makasim)) +- \[consumption\] Do not print "Switch to queue xxx" if queue the same. [\#11](https://github.com/php-enqueue/enqueue-dev/pull/11) ([makasim](https://github.com/makasim)) + +## [0.2.3](https://github.com/php-enqueue/enqueue-dev/tree/0.2.3) (2017-01-09) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.2...0.2.3) + +**Merged pull requests:** + +- Auto generate changelog [\#10](https://github.com/php-enqueue/enqueue-dev/pull/10) ([makasim](https://github.com/makasim)) +- \[travis\] Cache docker images on travis. [\#9](https://github.com/php-enqueue/enqueue-dev/pull/9) ([makasim](https://github.com/makasim)) +- \[enhancement\]\[amqp-ext\] Add purge queue method to amqp context. [\#8](https://github.com/php-enqueue/enqueue-dev/pull/8) ([makasim](https://github.com/makasim)) +- \[bug\]\[amqp-ext\] Receive timeout parameter is miliseconds [\#7](https://github.com/php-enqueue/enqueue-dev/pull/7) ([makasim](https://github.com/makasim)) + +## [0.2.2](https://github.com/php-enqueue/enqueue-dev/tree/0.2.2) (2017-01-06) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.1...0.2.2) + +**Merged pull requests:** + +- \[amqp\] introduce lazy context. [\#6](https://github.com/php-enqueue/enqueue-dev/pull/6) ([makasim](https://github.com/makasim)) + +## [0.2.1](https://github.com/php-enqueue/enqueue-dev/tree/0.2.1) (2017-01-05) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.2.0...0.2.1) + +## [0.2.0](https://github.com/php-enqueue/enqueue-dev/tree/0.2.0) (2017-01-05) +[Full Changelog](https://github.com/php-enqueue/enqueue-dev/compare/0.1.0...0.2.0) + +**Merged pull requests:** + +- Upd php cs fixer [\#3](https://github.com/php-enqueue/enqueue-dev/pull/3) ([makasim](https://github.com/makasim)) +- \[psr\] Introduce MessageProcessor interface \(moved from consumption\). [\#2](https://github.com/php-enqueue/enqueue-dev/pull/2) ([makasim](https://github.com/makasim)) +- \[bundle\] Add ability to disable signal extension. [\#1](https://github.com/php-enqueue/enqueue-dev/pull/1) ([makasim](https://github.com/makasim)) + +## [0.1.0](https://github.com/php-enqueue/enqueue-dev/tree/0.1.0) (2016-12-29) + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..21bb45cc5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +Contributing +------------ + +Enqueue is an open source, community-driven project. + +If you'd like to contribute, please read the following documents: + +* [Contribution](docs/contribution.md) diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 1174d4657..000000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,20 +0,0 @@ -FROM ubuntu:16.04 - -## libs -RUN set -x && \ - apt-get update && \ - apt-get install -y --no-install-recommends wget curl openssl ca-certificates nano netcat && \ - apt-get install -y --no-install-recommends php php-mysql php-curl php-intl php-mbstring php-zip php-mcrypt php-xdebug php-bcmath php-xml php-amqp - -## confis - -# RUN rm -f /etc/php/7.0/cli/conf.d/*xdebug.ini - -COPY ./docker/php/cli.ini /etc/php/7.0/cli/conf.d/1-dev_cli.ini -COPY ./docker/bin/dev_entrypoiny.sh /usr/local/bin/entrypoint.sh -RUN chmod u+x /usr/local/bin/entrypoint.sh - -RUN mkdir -p /mqdev -WORKDIR /mqdev - -CMD /usr/local/bin/entrypoint.sh diff --git a/Dockerfile.rabbitmq b/Dockerfile.rabbitmq deleted file mode 100644 index 96a41dc34..000000000 --- a/Dockerfile.rabbitmq +++ /dev/null @@ -1,10 +0,0 @@ -FROM rabbitmq:3-management - -RUN apt-get update -RUN apt-get install -y curl - -RUN curl http://www.rabbitmq.com/community-plugins/v3.6.x/rabbitmq_delayed_message_exchange-0.0.1.ez > $RABBITMQ_HOME/plugins/rabbitmq_delayed_message_exchange-0.0.1.ez -RUN rabbitmq-plugins enable --offline rabbitmq_delayed_message_exchange -RUN rabbitmq-plugins enable --offline rabbitmq_stomp - -EXPOSE 61613 \ No newline at end of file diff --git a/README.md b/README.md index 95ff0d539..5e0dacec3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,135 @@ -# Message Queue. Development Repository +[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) -[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/enqueue-dev.png?branch=master)](https://travis-ci.org/php-enqueue/enqueue-dev) +

Enqueue logo

-This is where all development happens. The repository provides a friendly environment for productive development, testing. +

+ Enqueue Chat + Build Status + Total Downloads + Latest Stable Version + License +

+ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become our client](http://forma-pro.com/) + +--- + +## Introduction + +**Enqueue** is production ready, battle-tested messaging solution for PHP. Provides a common way for programs to create, send, read messages. + +This is a main development repository. It provides a friendly environment for productive development and testing of all Enqueue related features&packages. + +Features: + +* [Feature rich](docs/quick_tour.md). + +* Adopts [queue interoperable](https://github.com/queue-interop/queue-interop) interfaces (inspired by [Java JMS](https://docs.oracle.com/javaee/7/api/javax/jms/package-summary.html)). +* Battle-tested. Used in production. +* Supported transports + * [AMQP(s)](https://php-enqueue.github.io/transport/amqp/) based on [PHP AMQP extension](https://github.com/pdezwart/php-amqp) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-ext/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-ext/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-ext/d/total.png)](https://packagist.org/packages/enqueue/amqp-ext/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-ext/version.png)](https://packagist.org/packages/enqueue/amqp-ext) + * [AMQP](https://php-enqueue.github.io/transport/amqp_bunny/) based on [bunny](https://github.com/jakubkulhan/bunny) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-bunny/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-bunny/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-bunny/d/total.png)](https://packagist.org/packages/enqueue/amqp-bunny/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-bunny/version.png)](https://packagist.org/packages/enqueue/amqp-bunny) + * [AMQP(s)](https://php-enqueue.github.io/transport/amqp_lib/) based on [php-amqplib](https://github.com/php-amqplib/php-amqplib) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-lib/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-lib/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-lib/d/total.png)](https://packagist.org/packages/enqueue/amqp-lib/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-lib/version.png)](https://packagist.org/packages/enqueue/amqp-lib) + * [Beanstalk](https://php-enqueue.github.io/transport/pheanstalk/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/pheanstalk/ci.yml?branch=master)](https://github.com/php-enqueue/pheanstalk/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/pheanstalk/d/total.png)](https://packagist.org/packages/enqueue/pheanstalk/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/pheanstalk/version.png)](https://packagist.org/packages/enqueue/pheanstalk) + * [STOMP](https://php-enqueue.github.io/transport/stomp/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/stomp/ci.yml?branch=master)](https://github.com/php-enqueue/stomp/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/stomp/d/total.png)](https://packagist.org/packages/enqueue/stomp/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/stomp/version.png)](https://packagist.org/packages/enqueue/stomp) + * [Amazon SQS](https://php-enqueue.github.io/transport/sqs/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/sqs/ci.yml?branch=master)](https://github.com/php-enqueue/sqs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/sqs/d/total.png)](https://packagist.org/packages/enqueue/sqs/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/sqs/version.png)](https://packagist.org/packages/enqueue/sqs) + * [Amazon SNS](https://php-enqueue.github.io/transport/sns/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/sns/ci.yml?branch=master)](https://github.com/php-enqueue/sns/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/sns/d/total.png)](https://packagist.org/packages/enqueue/sns/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/sns/version.png)](https://packagist.org/packages/enqueue/sns) + * [Amazon SNS\SQS](https://php-enqueue.github.io/transport/snsqs/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/snsqs/ci.yml?branch=master)](https://github.com/php-enqueue/snsqs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/snsqs/d/total.png)](https://packagist.org/packages/enqueue/snsqs/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/snsqs/version.png)](https://packagist.org/packages/enqueue/snsqs) + * [Google PubSub](https://php-enqueue.github.io/transport/gps/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/gps/ci.yml?branch=master)](https://github.com/php-enqueue/gps/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/gps/d/total.png)](https://packagist.org/packages/enqueue/gps/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/gps/version.png)](https://packagist.org/packages/enqueue/gps) + * [Kafka](https://php-enqueue.github.io/transport/kafka/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/rdkafka/ci.yml?branch=master)](https://github.com/php-enqueue/rdkafka/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/rdkafka/d/total.png)](https://packagist.org/packages/enqueue/rdkafka/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/rdkafka/version.png)](https://packagist.org/packages/enqueue/rdkafka) + * [Redis](https://php-enqueue.github.io/transport/redis/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/redis/ci.yml?branch=master)](https://github.com/php-enqueue/redis/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/redis/d/total.png)](https://packagist.org/packages/enqueue/redis/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/redis/version.png)](https://packagist.org/packages/enqueue/redis) + * [Gearman](https://php-enqueue.github.io/transport/gearman/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/gearman/ci.yml?branch=master)](https://github.com/php-enqueue/gearman/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/gearman/d/total.png)](https://packagist.org/packages/enqueue/gearman/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/gearman/version.png)](https://packagist.org/packages/enqueue/gearman) + * [Doctrine DBAL](https://php-enqueue.github.io/transport/dbal/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/dbal/ci.yml?branch=master)](https://github.com/php-enqueue/dbal/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/dbal/d/total.png)](https://packagist.org/packages/enqueue/dbal/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/dbal/version.png)](https://packagist.org/packages/enqueue/dbal) + * [Filesystem](https://php-enqueue.github.io/transport/filesystem/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/fs/ci.yml?branch=master)](https://github.com/php-enqueue/fs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/fs/d/total.png)](https://packagist.org/packages/enqueue/fs/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/fs/version.png)](https://packagist.org/packages/enqueue/fs) + * [Mongodb](https://php-enqueue.github.io/transport/mongodb/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/mongodb/ci.yml?branch=master)](https://github.com/php-enqueue/mongodb/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/mongodb/d/total.png)](https://packagist.org/packages/enqueue/mongodb/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/mongodb/version.png)](https://packagist.org/packages/enqueue/mongodb) + * [WAMP](https://php-enqueue.github.io/transport/wamp/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/wamp/ci.yml?branch=master)](https://github.com/php-enqueue/wamp/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/wamp/d/total.png)](https://packagist.org/packages/enqueue/wamp/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/wamp/version.png)](https://packagist.org/packages/enqueue/wamp) + * [Null](https://php-enqueue.github.io/transport/null/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/null/ci.yml?branch=master)](https://github.com/php-enqueue/null/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/null/d/total.png)](https://packagist.org/packages/enqueue/null/stats) +[![Latest Stable Version](https://poser.pugx.org/enqueue/null/version.png)](https://packagist.org/packages/enqueue/null) + * [the others are coming](https://github.com/php-enqueue/enqueue-dev/issues/284) +* [Symfony bundle](https://php-enqueue.github.io/bundle/quick_tour/) +* [Magento1 extension](https://php-enqueue.github.io/magento/quick_tour/) +* [Magento2 module](https://php-enqueue.github.io/magento2/quick_tour/) +* [Laravel extension](https://php-enqueue.github.io/laravel/quick_tour/) +* [Yii2. Amqp driver](https://php-enqueue.github.io/yii/amqp_driver/) +* [Message bus](https://php-enqueue.github.io/quick_tour/#client) support. +* [RPC over MQ](https://php-enqueue.github.io/quick_tour/#remote-procedure-call-rpc) support. +* [Monitoring](https://php-enqueue.github.io/monitoring/) +* Temporary queues support. +* Well designed, decoupled and reusable components. +* Carefully tested (unit & functional). +* For more visit [quick tour](https://php-enqueue.github.io/quick_tour/). ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) -* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Site](https://enqueue.forma-pro.com/) +* [Quick tour](https://php-enqueue.github.io/quick_tour/) +* [Documentation](https://php-enqueue.github.io/) +* [Blog](https://php-enqueue.github.io/#blogs) +* [Chat\Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 000000000..6b64e44d9 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,89 @@ +# Upgrading Enqueue: + +From `0.8.x` to `0.9.x`: + +## Processor declaration + +`Interop\Queue\PsrProcessor` interface has been replaced by `Interop\Queue\Processor` +`Interop\Queue\PsrMessage` interface has been replaced by `Interop\Queue\Message` +`Interop\Queue\PsrContext` interface has been replaced by `Interop\Queue\Context` + + + +## Symfony Bundle + +### Configuration changes: + +`0.8.x` + + +``` +enqueue: + transport: + default: ... +``` + +`0.9.x` + + +``` +enqueue: + default: + transport: ... +``` + +In `0.9.x` the client name is a root config node. + +The `default_processor_queue` Client option was removed. + +### Service declarations: + +`0.8.x` + + +``` +tags: + - { name: 'enqueue.client.processor' } +``` + +`0.9.x` + + +``` +tags: + - { name: 'enqueue.command_subscriber' } + - { name: 'enqueue.topic_subscriber' } + - { name: 'enqueue.processor' } +``` + +The tag to register message processors has changed and is now split into processor sub types. + +### CommandSubscriberInterface `getSubscribedCommand` + + +`0.8.x` + +return `aCommandName` or +``` + [ + 'processorName' => 'aCommandName', + 'queueName' => 'a_client_queue_name', + 'queueNameHardcoded' => true, + 'exclusive' => true, + ] +``` + +`0.9.x` + + +return `aCommandName` or +``` + [ + 'command' => 'aSubscribedCommand', + 'processor' => 'aProcessorName', + 'queue' => 'a_client_queue_name', + 'prefix_queue' => true, + 'exclusive' => true, + ] +``` + diff --git a/bin/build-enqueue-dev-image.sh b/bin/build-enqueue-dev-image.sh new file mode 100755 index 000000000..117d547f7 --- /dev/null +++ b/bin/build-enqueue-dev-image.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +(cd docker && docker build --rm --force-rm --no-cache --pull --squash --tag "enqueue/dev:latest" -f Dockerfile .) +(cd docker && docker login --username="$DOCKER_USER" --password="$DOCKER_PASSWORD") +(cd docker && docker push "enqueue/dev:latest") \ No newline at end of file diff --git a/bin/build-rabbitmq-image.sh b/bin/build-rabbitmq-image.sh new file mode 100755 index 000000000..1045ff787 --- /dev/null +++ b/bin/build-rabbitmq-image.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e +set -x + +(cd docker && docker build --rm --force-rm --no-cache --pull --squash --tag "enqueue/rabbitmq-local-build" -f Dockerfile."$1"-rabbitmq .) +(cd docker && docker login --username="$DOCKER_USER" --password="$DOCKER_PASSWORD") +(cd docker && docker tag enqueue/rabbitmq-local-build enqueue/rabbitmq:"$1") +(cd docker && docker push "enqueue/rabbitmq:$1") diff --git a/bin/build-rabbitmq-ssl-image.sh b/bin/build-rabbitmq-ssl-image.sh new file mode 100755 index 000000000..5bb52043a --- /dev/null +++ b/bin/build-rabbitmq-ssl-image.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -e +set -x + +mkdir -p /tmp/roboconf +rm -rf /tmp/roboconf/* + +(cd /tmp/roboconf && git clone git@github.com:roboconf/rabbitmq-with-ssl-in-docker.git) + +(cd /tmp/roboconf/rabbitmq-with-ssl-in-docker && docker build --rm --force-rm --no-cache --pull --squash --tag "enqueue/rabbitmq-ssl:latest" .) + +(cd /tmp/roboconf/rabbitmq-with-ssl-in-docker && docker login --username="$DOCKER_USER" --password="$DOCKER_PASSWORD") +(cd /tmp/roboconf/rabbitmq-with-ssl-in-docker && docker push "enqueue/rabbitmq-ssl:latest") + +docker run --rm -v "`pwd`/var/rabbitmq_certificates:/enqueue" "enqueue/rabbitmq-ssl:latest" cp /home/testca/cacert.pem /enqueue/cacert.pem +docker run --rm -v "`pwd`/var/rabbitmq_certificates:/enqueue" "enqueue/rabbitmq-ssl:latest" /bin/bash -c "/bin/bash /home/generate-client-keys.sh && cp /home/client/key.pem /enqueue/key.pem && cp /home/client/cert.pem /enqueue/cert.pem" diff --git a/bin/changelog b/bin/changelog new file mode 100755 index 000000000..ba2db0813 --- /dev/null +++ b/bin/changelog @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e + +if (( "$#" != 1 )) +then + echo "Tag has to be provided" + exit 1 +fi + +docker compose run -e CHANGELOG_GITHUB_TOKEN=${CHANGELOG_GITHUB_TOKEN:-""} --workdir="/mqdev" --rm generate-changelog github_changelog_generator --future-release "$1" --no-issues --unreleased-only --output "CHANGELOG_FUTURE.md" + + git add CHANGELOG.md && git commit -m "Release $1" -S && git push origin "$CURRENT_BRANCH" diff --git a/bin/dev b/bin/dev index 11f87e39d..45a3e7124 100755 --- a/bin/dev +++ b/bin/dev @@ -3,16 +3,16 @@ set -x set -e -while getopts "bustef" OPTION; do +while getopts "bustefdp" OPTION; do case $OPTION in b) - COMPOSE_PROJECT_NAME=mqdev docker-compose build + docker compose pull -q && docker compose build ;; u) - COMPOSE_PROJECT_NAME=mqdev docker-compose up + docker compose up ;; s) - COMPOSE_PROJECT_NAME=mqdev docker-compose stop + docker compose stop ;; e) docker exec -it mqdev_dev_1 /bin/bash @@ -20,10 +20,9 @@ while getopts "bustef" OPTION; do f) ./bin/php-cs-fixer fix ;; - t) - COMPOSE_PROJECT_NAME=mqdev docker-compose run --workdir="/mqdev" --rm dev ./bin/test - ;; + d) docker compose run --workdir="/mqdev" --rm dev php pkg/enqueue-bundle/Tests/Functional/app/console.php config:dump-reference enqueue -vvv + ;; \?) echo "Invalid option: -$OPTARG" >&2 exit 1 diff --git a/bin/fix-symfony-version.php b/bin/fix-symfony-version.php new file mode 100644 index 000000000..6eaafebab --- /dev/null +++ b/bin/fix-symfony-version.php @@ -0,0 +1,14 @@ +/dev/null', $phpBin, $projectRootDir.'/'.$file), $output, $returnCode); + exec(sprintf('%s -l %s', $phpBin, $projectRootDir.'/'.$file), $commandOutput, $returnCode); if ($returnCode) { - $filesWithErrors[] = $file; + $output[] = $commandOutput; } } - return $filesWithErrors; + return $output; } function runPhpCsFixer() @@ -101,46 +103,71 @@ function runPhpCsFixer() } $filesWithErrors = array(); - foreach (getFilesToFix() as $file) { - $output = ''; - $returnCode = null; + $output = ''; + $returnCode = null; + + exec(sprintf( + '%s %s fix --config=.php_cs.php --dry-run --no-interaction --path-mode=intersection -- %s', + $phpBin, + $phpCsFixerBin, + implode(' ', getFilesToFix()) + ), $output, $returnCode); + + if ($returnCode) { exec(sprintf( - '%s %s fix %s', + '%s %s fix --config=.php_cs.php --no-interaction -v --path-mode=intersection -- %s', $phpBin, $phpCsFixerBin, - $projectRootDir.'/'.$file - ), $output, $returnCode); - - if ($returnCode) { - $filesWithErrors[] = $file; - } + implode(' ', getFilesToFix()) + ), $output); } - return $filesWithErrors; + return $returnCode; +} + +function runPhpstan() +{ + $output = ''; + $returnCode = null; + + exec(sprintf( + 'docker run --workdir="/mqdev" -v "`pwd`:/mqdev" --rm enqueue/dev:latest php -d memory_limit=1024M bin/phpstan analyse -l 1 -c phpstan.neon %s', + implode(' ', getFilesToFix()) + ), $output, $returnCode); + + return $returnCode ? $output : false; } +echo sprintf('Found %s staged files', count(getFilesToFix())).PHP_EOL; + $phpSyntaxErrors = runPhpLint(); if ($phpSyntaxErrors) { echo "Php syntax errors were found in next files:" . PHP_EOL; - - foreach ($phpSyntaxErrors as $error) { - echo $error . PHP_EOL; + foreach ($phpSyntaxErrors as $phpSyntaxErrors) { + echo array_walk_recursive($phpSyntaxErrors, function($item) { + echo $item.PHP_EOL; + }) . PHP_EOL; } exit(1); } -$phpCSFixerErrors = runPhpCsFixer(); -if ($phpCSFixerErrors) { +$phpCSFixed = runPhpCsFixer(); +if ($phpCSFixed) { echo "Incorrect coding standards were detected and fixed." . PHP_EOL; echo "Please stash changes and run commit again." . PHP_EOL; - echo "List of changed files:" . PHP_EOL; - foreach ($phpCSFixerErrors as $error) { - echo $error . PHP_EOL; - } + exit(1); +} + +$phpStanErrors = runPhpstan(); +if ($phpStanErrors) { + echo array_walk_recursive($phpStanErrors, function($item) { + echo $item.PHP_EOL; + }) . PHP_EOL; + echo PHP_EOL; exit(1); } -exit(0); \ No newline at end of file +exit(0); diff --git a/bin/release b/bin/release index 4a8b66c45..f1f7a07f1 100755 --- a/bin/release +++ b/bin/release @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -e -set -x if (( "$#" != 1 )) then @@ -9,12 +8,26 @@ then exit 1 fi -./bin/subtree-split + +if [ ! -f ./CHANGELOG_FUTURE.md ]; then + echo "Release changelog has not been generated. File CHANGELOG_FUTURE.md does not exist." + exit 1 +fi + +rm ./CHANGELOG_FUTURE.md CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` -for REMOTE in origin psr-queue enqueue amqp-ext enqueue-bundle job-queue stomp test +git add CHANGELOG.md && git commit -s -m "Release $1" -S && git push origin "$CURRENT_BRANCH" + +./bin/subtree-split + +for REMOTE in origin stomp amqp-ext amqp-lib amqp-bunny amqp-tools pheanstalk gearman sqs sns snsqs gps fs redis dbal null rdkafka enqueue simple-client enqueue-bundle job-queue test async-event-dispatcher async-command mongodb wamp monitoring dsn do + echo "" + echo "" + echo "Releasing $REMOTE"; + TMP_DIR="/tmp/enqueue-repo" REMOTE_URL=`git remote get-url $REMOTE` @@ -23,9 +36,30 @@ do ( cd $TMP_DIR; - git clone $REMOTE_URL . --depth=10 - git checkout $CURRENT_BRANCH; - git tag $1 -m "Release $1" - git push origin --tags + git clone $REMOTE_URL . + git checkout "$CURRENT_BRANCH"; + # gsort comes with coreutils packages. brew install coreutils + LAST_RELEASE=$(git tag -l [0-9].* | gsort -V | tail -n1 ) + if [[ -z "$LAST_RELEASE" ]]; then + echo "There has not been any releases. Releasing $1"; + + #git tag $1 -a -s -m "Release $1" + git tag $1 -a -m "Release $1" + git push origin --tags + else + echo "Last release $LAST_RELEASE"; + + CHANGES_SINCE_LAST_RELEASE=$(git log "$LAST_RELEASE"...master) + CHANGES_SINCE_LAST_RELEASE="$CHANGES_SINCE_LAST_RELEASE" | xargs echo -n + if [[ ! -z "$CHANGES_SINCE_LAST_RELEASE" ]]; then + echo "There are changes since last release. Releasing $1"; + + #git tag $1 -s -m "Release $1" + git tag $1 -m "Release $1" + git push origin --tags + else + echo "No change since last release."; + fi + fi ) done diff --git a/bin/splitsh-lite-m1 b/bin/splitsh-lite-m1 new file mode 100755 index 000000000..55814ab43 Binary files /dev/null and b/bin/splitsh-lite-m1 differ diff --git a/bin/subtree-split b/bin/subtree-split index 0d030e9e3..1d73a4e8c 100755 --- a/bin/subtree-split +++ b/bin/subtree-split @@ -10,8 +10,8 @@ function split() # split_new_repo $1 $2 - SHA1=`./bin/splitsh-lite --prefix=$1` - git push $2 "$SHA1:$CURRENT_BRANCH" + SHA1=`./bin/splitsh-lite-m1 --prefix=$1` + git push $2 "$SHA1:refs/heads/$CURRENT_BRANCH" } function split_new_repo() @@ -32,7 +32,7 @@ function split_new_repo() git push origin master; ); - SHA1=`./bin/splitsh-lite --prefix=$1` + SHA1=`./bin/splitsh-lite-m1 --prefix=$1` git fetch $2 git push $2 "$SHA1:$CURRENT_BRANCH" -f } @@ -43,18 +43,60 @@ function remote() git remote add $1 $2 || true } -remote psr-queue git@github.com:php-enqueue/psr-queue.git remote enqueue git@github.com:php-enqueue/enqueue.git +remote php-enqueue git@github.com:php-enqueue/php-enqueue.github.io.git +remote simple-client git@github.com:php-enqueue/simple-client.git remote stomp git@github.com:php-enqueue/stomp.git remote amqp-ext git@github.com:php-enqueue/amqp-ext.git +remote amqp-lib git@github.com:php-enqueue/amqp-lib.git +remote amqp-bunny git@github.com:php-enqueue/amqp-bunny.git +remote amqp-tools git@github.com:php-enqueue/amqp-tools.git +remote pheanstalk git@github.com:php-enqueue/pheanstalk.git +remote gearman git@github.com:php-enqueue/gearman.git +remote fs git@github.com:php-enqueue/fs.git +remote redis git@github.com:php-enqueue/redis.git +remote rdkafka git@github.com:php-enqueue/rdkafka.git +remote dbal git@github.com:php-enqueue/dbal.git +remote null git@github.com:php-enqueue/null.git +remote sqs git@github.com:php-enqueue/sqs.git +remote sns git@github.com:php-enqueue/sns.git +remote snsqs git@github.com:php-enqueue/snsqs.git +remote gps git@github.com:php-enqueue/gps.git remote enqueue-bundle git@github.com:php-enqueue/enqueue-bundle.git remote job-queue git@github.com:php-enqueue/job-queue.git remote test git@github.com:php-enqueue/test.git +remote async-event-dispatcher git@github.com:php-enqueue/async-event-dispatcher.git +remote async-command git@github.com:php-enqueue/async-command.git +remote mongodb git@github.com:php-enqueue/mongodb.git +remote dsn git@github.com:php-enqueue/dsn.git +remote wamp git@github.com:php-enqueue/wamp.git +remote monitoring git@github.com:php-enqueue/monitoring.git -split 'pkg/psr-queue' psr-queue split 'pkg/enqueue' enqueue +split 'docs' php-enqueue +split 'pkg/simple-client' simple-client split 'pkg/stomp' stomp split 'pkg/amqp-ext' amqp-ext +split 'pkg/amqp-lib' amqp-lib +split 'pkg/amqp-bunny' amqp-bunny +split 'pkg/amqp-tools' amqp-tools +split 'pkg/pheanstalk' pheanstalk +split 'pkg/gearman' gearman +split 'pkg/rdkafka' rdkafka +split 'pkg/fs' fs +split 'pkg/redis' redis +split 'pkg/dbal' dbal +split 'pkg/null' null +split 'pkg/sqs' sqs +split 'pkg/sns' sns +split 'pkg/snsqs' snsqs +split 'pkg/gps' gps split 'pkg/enqueue-bundle' enqueue-bundle split 'pkg/job-queue' job-queue split 'pkg/test' test +split 'pkg/async-event-dispatcher' async-event-dispatcher +split 'pkg/async-command' async-command +split 'pkg/mongodb' mongodb +split 'pkg/dsn' dsn +split 'pkg/wamp' wamp +split 'pkg/monitoring' monitoring diff --git a/bin/test b/bin/test deleted file mode 100755 index ab293690f..000000000 --- a/bin/test +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -# wait for service -# $1 host -# $2 port -# $3 attempts -function waitForService() -{ - ATTEMPTS=0 - until nc -z $1 $2; do - printf "wait for service %s:%s\n" $1 $2 - ((ATTEMPTS++)) - if [ $ATTEMPTS -ge $3 ]; then - printf "service is not running %s:%s\n" $1 $2 - exit 1 - fi - sleep 1 - done - - printf "service is online %s:%s\n" $1 $2 -} - -waitForService rabbitmq 5672 50 -waitForService mysql 3306 50 - -php pkg/job-queue/Tests/Functional/app/console doctrine:database:create -php pkg/job-queue/Tests/Functional/app/console doctrine:schema:update --force - -bin/phpunit "$@" diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 000000000..5cb858ad6 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -x +set -e + +docker compose run --workdir="/mqdev" --rm dev ./docker/bin/test.sh $@ diff --git a/composer.json b/composer.json index cce8a9151..75ff4424a 100644 --- a/composer.json +++ b/composer.json @@ -1,56 +1,138 @@ { - "name": "enqueue/dev-message-queue", + "name": "enqueue/enqueue-dev", "type": "project", - "minimum-stability": "dev", + "minimum-stability": "stable", + "homepage": "https://enqueue.forma-pro.com/", + "scripts": { + "cs-fix": "bin/php-cs-fixer fix", + "cs-lint": "bin/php-cs-fixer fix --no-interaction --dry-run --diff", + "phpstan": "bin/phpstan analyse --memory-limit=512M -c phpstan.neon" + }, "require": { - "php": ">=5.6", - "enqueue/psr-queue": "*", - "enqueue/enqueue": "*", - "enqueue/stomp": "*", - "enqueue/amqp-ext": "*", - "enqueue/enqueue-bundle": "*", - "enqueue/job-queue": "*", - "enqueue/test": "*" + "php": "^8.1", + + "ext-amqp": "^1.9.3|^2.0.0", + "ext-gearman": "^2.0", + "ext-mongodb": "^1.17", + "ext-rdkafka": "^4.0|^5.0|^6.0", + + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8.1", + "bunny/bunny": "^0.5.5", + "php-amqplib/php-amqplib": "^3.1", + "doctrine/dbal": "^3.2", + "ramsey/uuid": "^4.3", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "psr/container": "^1.1 || ^2.0", + "makasim/temp-file": "^0.2", + "google/cloud-pubsub": "^1.46", + "doctrine/orm": "^2.12", + "doctrine/persistence": "^2.0|^3.0", + "mongodb/mongodb": "^1.17", + "pda/pheanstalk": "^3.1", + "aws/aws-sdk-php": "^3.290", + "stomp-php/stomp-php": "^5.1", + "php-http/guzzle7-adapter": "^0.1.1", + "php-http/client-common": "^2.2.1", + "andrewmy/rabbitmq-management-api": "^2.1.2", + "predis/predis": "^2.1", + "thruway/client": "^0.5.5", + "thruway/pawl-transport": "^0.5.2", + "influxdb/influxdb-php": "^1.14", + "datadog/php-datadogstatsd": "^1.3", + "guzzlehttp/guzzle": "^7.0.1", + "guzzlehttp/psr7": "^1.9.1", + "php-http/discovery": "^1.13", + "voryx/thruway-common": "^1.0.1", + "react/dns": "^1.4", + "react/event-loop": "^1.2", + "react/promise": "^2.8" }, "require-dev": { - "phpunit/phpunit": "^5", - "doctrine/doctrine-bundle": "~1.2", - "symfony/monolog-bundle": "^2.8|^3", - "symfony/browser-kit": "^2.8|^3", - "symfony/expression-language": "^2.8|^3", - "friendsofphp/php-cs-fixer": "^2" - }, - "config": { - "bin-dir": "bin" + "ext-pcntl": "*", + "phpunit/phpunit": "^9.5.28", + "phpstan/phpstan": "^1.0", + "queue-interop/queue-spec": "^0.6.2", + "symfony/browser-kit": "^6.2|^7.0", + "symfony/config": "^6.2|^7.0", + "symfony/process": "^6.2|^7.0", + "symfony/console": "^6.2|^7.0", + "symfony/dependency-injection": "^6.2|^7.0", + "symfony/event-dispatcher": "^6.2|^7.0", + "symfony/expression-language": "^6.2|^7.0", + "symfony/http-kernel": "^6.2|^7.0", + "symfony/filesystem": "^6.2|^7.0", + "symfony/framework-bundle": "^6.2|^7.0", + "symfony/validator": "^6.2|^7.0", + "symfony/yaml": "^6.2|^7.0", + "empi89/php-amqp-stubs": "*@dev", + "doctrine/doctrine-bundle": "^2.5", + "doctrine/mongodb-odm-bundle": "^4.7|^5.0", + "alcaeus/mongo-php-adapter": "^1.0", + "kwn/php-rdkafka-stubs": "^2.0.3", + "friendsofphp/php-cs-fixer": "^3.4", + "dms/phpunit-arraysubset-asserts": "^0.2.1", + "phpspec/prophecy-phpunit": "^2.0" }, - "repositories": [ - { - "type": "path", - "url": "pkg/psr-queue" - }, - { - "type": "path", - "url": "pkg/test" - }, - { - "type": "path", - "url": "pkg/enqueue" + "autoload": { + "psr-4": { + "Enqueue\\AmqpBunny\\": "pkg/amqp-bunny/", + "Enqueue\\AmqpExt\\": "pkg/amqp-ext/", + "Enqueue\\AmqpLib\\": "pkg/amqp-lib/", + "Enqueue\\AmqpTools\\": "pkg/amqp-tools/", + "Enqueue\\AsyncEventDispatcher\\": "pkg/async-event-dispatcher/", + "Enqueue\\AsyncCommand\\": "pkg/async-command/", + "Enqueue\\Dbal\\": "pkg/dbal/", + "Enqueue\\Bundle\\": "pkg/enqueue-bundle/", + "Enqueue\\Fs\\": "pkg/fs/", + "Enqueue\\Gearman\\": "pkg/gearman/", + "Enqueue\\Gps\\": "pkg/gps/", + "Enqueue\\JobQueue\\": "pkg/job-queue/", + "Enqueue\\Mongodb\\": "pkg/mongodb/", + "Enqueue\\Null\\": "pkg/null/", + "Enqueue\\Pheanstalk\\": "pkg/pheanstalk/", + "Enqueue\\RdKafka\\": "pkg/rdkafka/", + "Enqueue\\Redis\\": "pkg/redis/", + "Enqueue\\SimpleClient\\": "pkg/simple-client/", + "Enqueue\\Sqs\\": "pkg/sqs/", + "Enqueue\\Sns\\": "pkg/sns/", + "Enqueue\\SnsQs\\": "pkg/snsqs/", + "Enqueue\\Stomp\\": "pkg/stomp/", + "Enqueue\\Test\\": "pkg/test/", + "Enqueue\\Dsn\\": "pkg/dsn/", + "Enqueue\\Wamp\\": "pkg/wamp/", + "Enqueue\\Monitoring\\": "pkg/monitoring/", + "Enqueue\\": "pkg/enqueue/" }, - { - "type": "path", - "url": "pkg/stomp" - }, - { - "type": "path", - "url": "pkg/amqp-ext" + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "autoload-dev": { + "files": [ + "pkg/rdkafka/Tests/bootstrap.php" + ], + "psr-0": { + "RdKafka": "vendor/kwn/php-rdkafka-stubs/stubs" }, - { - "type": "path", - "url": "pkg/enqueue-bundle" + "psr-4": { + "RdKafka\\": "vendor/kwn/php-rdkafka-stubs/stubs/RdKafka" + } + }, + "config": { + "bin-dir": "bin", + "platform": { + "ext-amqp": "1.9.3", + "ext-gearman": "2.0.3", + "ext-rdkafka": "4.0", + "ext-bcmath": "1", + "ext-mbstring": "1", + "ext-mongodb": "1.17.3", + "ext-sockets": "1" }, - { - "type": "path", - "url": "pkg/job-queue" + "prefer-stable": true, + "allow-plugins": { + "php-http/discovery": false } - ] + } } diff --git a/docker-compose.yml b/docker-compose.yml index 74ffecbcb..b724d7881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,43 +1,149 @@ -version: '2' services: dev: - build: { context: ., dockerfile: Dockerfile.dev } + # when image publishing gets sorted: + # image: enqueue/dev:${PHP_VERSION:-8.2} + build: + context: docker + args: + PHP_VERSION: "${PHP_VERSION:-8.2}" depends_on: - rabbitmq - mysql + - postgres + - redis + - beanstalkd + - gearmand + - kafka + - zookeeper + - google-pubsub + - rabbitmqssl + - mongo + - thruway + - localstack volumes: - - ./:/mqdev + - './:/mqdev' environment: - - SYMFONY__RABBITMQ__HOST=rabbitmq - - SYMFONY__RABBITMQ__USER=guest - - SYMFONY__RABBITMQ__PASSWORD=guest - - SYMFONY__RABBITMQ__VHOST=mqdev - - SYMFONY__RABBITMQ__AMQP__PORT=5672 - - SYMFONY__RABBITMQ__STOMP__PORT=61613 - - SYMFONY__DB__DRIVER=pdo_mysql - - SYMFONY__DB__HOST=mysql - - SYMFONY__DB__PORT=3306 - - SYMFONY__DB__NAME=mqdev - - SYMFONY__DB__USER=root - - SYMFONY__DB__PASSWORD=rootpass + - AMQP_DSN=amqp://guest:guest@rabbitmq:5672/mqdev + - RABBITMQ_AMQP_DSN=amqp+rabbitmq://guest:guest@rabbitmq:5672/mqdev + - AMQPS_DSN=amqps://guest:guest@rabbitmqssl:5671 + - STOMP_DSN=stomp://guest:guest@rabbitmq:61613/mqdev + - RABITMQ_STOMP_DSN=stomp+rabbitmq://guest:guest@rabbitmq:61613/mqdev + - RABBITMQ_MANAGMENT_DSN=http://guest:guest@rabbitmq:15672/mqdev + - DOCTRINE_DSN=mysql://root:rootpass@mysql/mqdev + - DOCTRINE_POSTGRES_DSN=postgres://postgres:pass@postgres/template1 + - MYSQL_DSN=mysql://root:rootpass@mysql/mqdev + - POSTGRES_DSN=postgres://postgres:pass@postgres/postgres + - PREDIS_DSN=redis+predis://redis + - PHPREDIS_DSN=redis+phpredis://redis + - GPS_DSN=gps:?projectId=mqdev&emulatorHost=http://google-pubsub:8085 + - SQS_DSN=sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4566&version=latest + - SNS_DSN=sns:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4566&version=latest + - SNSQS_DSN=snsqs:?key=key&secret=secret®ion=us-east-1&sns_endpoint=http://localstack:4566&sqs_endpoint=http://localstack:4566&version=latest + - WAMP_DSN=wamp://thruway:9090 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - AWS_SQS_KEY=key + - AWS_SQS_SECRET=secret + - AWS_SQS_REGION=us-east-1 + - AWS_SQS_ENDPOINT=http://localstack:4566 + - AWS_SQS_VERSION=latest + - BEANSTALKD_DSN=beanstalk://beanstalkd:11300 + - GEARMAN_DSN=gearman://gearmand:4730 + - MONGO_DSN=mongodb://mongo + - RDKAFKA_HOST=kafka + - RDKAFKA_PORT=9092 rabbitmq: - build: { context: ., dockerfile: Dockerfile.rabbitmq } - ports: - - "15672:15672" + image: 'enqueue/rabbitmq:3.7' environment: - RABBITMQ_DEFAULT_USER=guest - RABBITMQ_DEFAULT_PASS=guest - RABBITMQ_DEFAULT_VHOST=mqdev - mysql: - image: mariadb:10 ports: - - "3306:3306" - volumes: - - mysql-data:/var/lib/mysql + - "15677:15672" + + rabbitmqssl: + image: 'enqueue/rabbitmq-ssl:latest' + environment: + - 'RABBITMQ_DEFAULT_USER=guest' + - 'RABBITMQ_DEFAULT_PASS=guest' + + beanstalkd: + image: 'jonbaldie/beanstalkd' + + gearmand: + image: 'artefactual/gearmand' + + redis: + image: 'redis:3' + ports: + - "6379:6379" + + mysql: + image: mysql:5.7 + platform: linux/amd64 environment: MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: mqdev + + postgres: + image: postgres + environment: + POSTGRES_PASSWORD: pass + + generate-changelog: + image: enqueue/generate-changelog:latest + # build: { context: docker, dockerfile: Dockerfile.generate-changelog } + volumes: + - ./:/mqdev -volumes: - mysql-data: - driver: local \ No newline at end of file + zookeeper: + image: 'wurstmeister/zookeeper' + ports: + - '2181:2181' + + kafka: + image: 'wurstmeister/kafka' + ports: + - '9092:9092' + environment: + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_ADVERTISED_HOST_NAME: kafka + KAFKA_ADVERTISED_PORT: 9092 + + google-pubsub: + image: 'google/cloud-sdk:latest' + entrypoint: 'gcloud beta emulators pubsub start --host-port=0.0.0.0:8085' + + mongo: + image: mongo:3.7 + ports: + - "27017:27017" + + thruway: + build: './docker/thruway' + ports: + - '9090:9090' + + localstack: + image: 'localstack/localstack:3.6.0' + ports: + - "127.0.0.1:4566:4566" # LocalStack Gateway + - "127.0.0.1:4510-4559:4510-4559" # external services port range + environment: + HOSTNAME_EXTERNAL: 'localstack' + SERVICES: 's3,sqs,sns' + + influxdb: + image: 'influxdb:latest' + + chronograf: + image: 'chronograf:latest' + entrypoint: 'chronograf --influxdb-url=http://influxdb:8086' + ports: + - '8888:8888' + + grafana: + image: 'grafana/grafana:latest' + ports: + - '3000:3000' diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..9e7a0b71b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,39 @@ +ARG PHP_VERSION=8.2 +FROM php:${PHP_VERSION}-alpine + +ARG PHP_VERSION + +RUN --mount=type=cache,target=/var/cache/apk apk add --no-cache $PHPIZE_DEPS \ + libpq-dev \ + librdkafka-dev \ + rabbitmq-c-dev \ + linux-headers && \ + apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing \ + gearman-dev + +# Install First Party Modules +RUN docker-php-ext-install -j$(nproc) \ + pcntl \ + pdo_mysql \ + pdo_pgsql + +# Install Third Party Modules +RUN --mount=type=cache,target=/tmp/pear pecl install redis \ + mongodb-1.21.0 \ + gearman \ + rdkafka \ + xdebug && \ + pecl install --configureoptions 'with-librabbitmq-dir="autodetect"' amqp +RUN docker-php-ext-enable redis mongodb gearman rdkafka xdebug amqp + +COPY ./php/cli.ini /usr/local/etc/php/conf.d/.user.ini +COPY ./bin/dev_entrypoiny.sh /usr/local/bin/entrypoint.sh +RUN mv /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini +RUN chmod u+x /usr/local/bin/entrypoint.sh + +RUN mkdir -p /mqdev +WORKDIR /mqdev + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/Dockerfile.3.6-rabbitmq b/docker/Dockerfile.3.6-rabbitmq new file mode 100644 index 000000000..9c9cc61b3 --- /dev/null +++ b/docker/Dockerfile.3.6-rabbitmq @@ -0,0 +1,18 @@ +FROM rabbitmq:3.6-management + +RUN apt-get update && \ + apt-get -y --no-install-recommends --no-install-suggests install ca-certificates curl unzip && \ + rm -rf /var/lib/apt/lists/* + +RUN curl https://dl.bintray.com/rabbitmq/community-plugins/3.6.x/rabbitmq_delayed_message_exchange/rabbitmq_delayed_message_exchange-20171215-3.6.x.zip > /tmp/delayed_plugin.zip +RUN cd /tmp && \ + unzip delayed_plugin.zip && \ + rm delayed_plugin.zip && \ + mv rabbitmq_delayed_message_exchange-20171215-3.6.x.ez $RABBITMQ_HOME/plugins/rabbitmq_delayed_message_exchange-20171215-3.6.x.ez + +RUN rabbitmq-plugins enable --offline rabbitmq_delayed_message_exchange +RUN rabbitmq-plugins enable --offline rabbitmq_stomp + +RUN apt-get purge -y --auto-remove ca-certificates curl unzip + +EXPOSE 61613 \ No newline at end of file diff --git a/docker/Dockerfile.3.7-rabbitmq b/docker/Dockerfile.3.7-rabbitmq new file mode 100644 index 000000000..116a3a96c --- /dev/null +++ b/docker/Dockerfile.3.7-rabbitmq @@ -0,0 +1,18 @@ +FROM rabbitmq:3.7-management + +RUN apt-get update && \ + apt-get -y --no-install-recommends --no-install-suggests install ca-certificates curl unzip && \ + rm -rf /var/lib/apt/lists/* + +RUN curl https://dl.bintray.com/rabbitmq/community-plugins/3.7.x/rabbitmq_delayed_message_exchange/rabbitmq_delayed_message_exchange-20171201-3.7.x.zip > /tmp/delayed_plugin.zip +RUN cd /tmp && \ + unzip delayed_plugin.zip && \ + rm delayed_plugin.zip && \ + mv rabbitmq_delayed_message_exchange-20171201-3.7.x.ez $RABBITMQ_HOME/plugins/rabbitmq_delayed_message_exchange-20171201-3.7.x.ez + +RUN rabbitmq-plugins enable --offline rabbitmq_delayed_message_exchange +RUN rabbitmq-plugins enable --offline rabbitmq_stomp + +RUN apt-get purge -y --auto-remove ca-certificates curl unzip + +EXPOSE 61613 \ No newline at end of file diff --git a/docker/Dockerfile.generate-changelog b/docker/Dockerfile.generate-changelog new file mode 100644 index 000000000..80067f48f --- /dev/null +++ b/docker/Dockerfile.generate-changelog @@ -0,0 +1,3 @@ +FROM ruby:2 + +RUN gem install github_changelog_generator \ No newline at end of file diff --git a/docker/bin/dev_entrypoiny.sh b/docker/bin/dev_entrypoiny.sh index a18b06b1e..c56e146fa 100644 --- a/docker/bin/dev_entrypoiny.sh +++ b/docker/bin/dev_entrypoiny.sh @@ -1,3 +1,3 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh while true; do sleep 1; done diff --git a/docker/bin/refresh-mysql-database.php b/docker/bin/refresh-mysql-database.php new file mode 100644 index 000000000..05e78f43d --- /dev/null +++ b/docker/bin/refresh-mysql-database.php @@ -0,0 +1,16 @@ +createContext(); + +$dbalContext->getDbalConnection()->getSchemaManager()->dropAndCreateDatabase($database); +$dbalContext->getDbalConnection()->exec('USE '.$database); +$dbalContext->createDataBaseTable(); + +echo 'MySQL Database is updated'.\PHP_EOL; diff --git a/docker/bin/refresh-postgres-database.php b/docker/bin/refresh-postgres-database.php new file mode 100644 index 000000000..1d96c3c07 --- /dev/null +++ b/docker/bin/refresh-postgres-database.php @@ -0,0 +1,14 @@ +createContext(); + +$dbalContext->getDbalConnection()->getSchemaManager()->dropAndCreateDatabase('postgres'); +$dbalContext->createDataBaseTable(); + +echo 'Postgresql Database is updated'.\PHP_EOL; diff --git a/docker/bin/test.sh b/docker/bin/test.sh new file mode 100755 index 000000000..1a6d35453 --- /dev/null +++ b/docker/bin/test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh + +# wait for service +# $1 host +# $2 port +# $3 attempts + +FORCE_EXIT=false + +function waitForService() +{ + ATTEMPTS=0 + until nc -z $1 $2; do + printf "wait for service %s:%s\n" $1 $2 + ATTEMPTS=$((ATTEMPTS++)) + if [ $ATTEMPTS -ge $3 ]; then + printf "service is not running %s:%s\n" $1 $2 + exit 1 + fi + if [ "$FORCE_EXIT" = true ]; then + exit; + fi + + sleep 1 + done + + printf "service is online %s:%s\n" $1 $2 +} + +trap "FORCE_EXIT=true" SIGTERM SIGINT + +waitForService rabbitmq 5672 50 +waitForService rabbitmqssl 5671 50 +waitForService mysql 3306 50 +waitForService postgres 5432 50 +waitForService redis 6379 50 +waitForService beanstalkd 11300 50 +waitForService gearmand 4730 50 +waitForService kafka 9092 50 +waitForService mongo 27017 50 +waitForService thruway 9090 50 +waitForService localstack 4566 50 + +php docker/bin/refresh-mysql-database.php || exit 1 +php docker/bin/refresh-postgres-database.php || exit 1 +php pkg/job-queue/Tests/Functional/app/console doctrine:database:create --if-not-exists || exit 1 +php pkg/job-queue/Tests/Functional/app/console doctrine:schema:update --force --complete || exit 1 + +#php pkg/enqueue-bundle/Tests/Functional/app/console.php config:dump-reference enqueue +php -d memory_limit=-1 bin/phpunit "$@" diff --git a/docker/php/cli.ini b/docker/php/cli.ini index 3154c04a4..40361c920 100644 --- a/docker/php/cli.ini +++ b/docker/php/cli.ini @@ -1,8 +1,6 @@ -error_reporting=E_ALL +error_reporting=E_ALL&~E_DEPRECATED&~E_USER_DEPRECATED display_errors=on memory_limit = 2G max_execution_time=0 date.timezone=UTC variables_order="EGPCS" - -extension=amqp.so \ No newline at end of file diff --git a/docker/thruway/Dockerfile b/docker/thruway/Dockerfile new file mode 100644 index 000000000..042a49d64 --- /dev/null +++ b/docker/thruway/Dockerfile @@ -0,0 +1,13 @@ +FROM makasim/nginx-php-fpm:7.4-all-exts + +RUN mkdir -p /thruway +WORKDIR /thruway + +# Thruway router +COPY --from=composer /usr/bin/composer /usr/bin/composer +RUN COMPOSER_HOME=/thruway composer global require --prefer-dist --no-scripts voryx/thruway + +COPY WsRouter.php . + +CMD ["/usr/bin/php", "WsRouter.php"] + diff --git a/docker/thruway/WsRouter.php b/docker/thruway/WsRouter.php new file mode 100644 index 000000000..ee5bb948a --- /dev/null +++ b/docker/thruway/WsRouter.php @@ -0,0 +1,14 @@ +addTransportProvider($transportProvider); + +$router->start(); diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..29949d465 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +/_site +/Gemfile.lock diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 000000000..5b4694d54 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,28 @@ +source "https://rubygems.org" + +# Hello! This is where you manage which Jekyll version is used to run. +# When you want to use a different version, change it below, save the +# file and run `bundle install`. Run Jekyll with `bundle exec`, like so: +# +# bundle exec jekyll serve +# +# This will help ensure the proper Jekyll version is running. +# Happy Jekylling! +gem "jekyll", "~> 3.8.5" + +# This is the default theme for new Jekyll sites. You may change this to anything you like. +# gem "minima", "~> 2.0" +gem "just-the-docs" + +# If you have any plugins, put them here! +group :jekyll_plugins do + gem "github-pages" +# gem "jekyll-feed", "~> 0.6" +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby] + +# Performance-booster for watching directories on Windows +gem "wdm", "~> 0.1.0" if Gem.win_platform? + diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 000000000..0e457d2e6 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,38 @@ +title: enqueue-dev +description: A Jekyll theme for documentation +baseurl: "" # the subpath of your site, e.g. /blog +url: "" # the base hostname & protocol for your site, e.g. http://example.com + +permalink: pretty +exclude: + - "node_modules/" + - "*.gemspec" + - "*.gem" + - "Gemfile" + - "Gemfile.lock" + - "package.json" + - "package-lock.json" + - "script/" + - "LICENSE.txt" + - "lib/" + - "bin/" + - "README.md" + - "Rakefile" + +markdown: kramdown + +# Enable or disable the site search +search_enabled: true + +# Aux links for the upper right navigation +aux_links: + "enqueue-dev on GitHub": + - "//github.com/php-enqueue/enqueue-dev" + +# Color scheme currently only supports "dark" or nil (default) +color_scheme: nil + +remote_theme: pmarsceill/just-the-docs + +plugins: + - jekyll-seo-tag diff --git a/docs/_includes/nav.html b/docs/_includes/nav.html new file mode 100644 index 000000000..fa2492acc --- /dev/null +++ b/docs/_includes/nav.html @@ -0,0 +1,62 @@ + diff --git a/docs/_includes/support.md b/docs/_includes/support.md new file mode 100644 index 000000000..3f8cf322a --- /dev/null +++ b/docs/_includes/support.md @@ -0,0 +1,7 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become our client](http://forma-pro.com/) + +--- diff --git a/docs/amqp_transport.md b/docs/amqp_transport.md deleted file mode 100644 index d777edd4e..000000000 --- a/docs/amqp_transport.md +++ /dev/null @@ -1,118 +0,0 @@ -# AMQP transport - -Implements [AMQP specifications](https://www.rabbitmq.com/specification.html). -Build on top of [php amqp extension](https://github.com/pdezwart/php-amqp). - -* [Create context](#create-context) -* [Declare topic](#declare-topic) -* [Declare queue](#decalre-queue) -* [Bind queue to topic](#bind-queue-to-topic) -* [Send message to topic](#send-message-to-topic) -* [Send message to queue](#send-message-to-queue) -* [Consume message](#consume-message) - -## Create context - -```php - '127.0.0.1', - 'port' => 5672, - 'vhost' => '/', - 'login' => 'guest', - 'password' => 'guest', - 'persisted' => false, -]); - -$psrContext = $connectionFactory->createContext(); -``` - -## Declare topic. - -Declare topic operation creates a topic on a broker side. - -```php -createTopic('foo'); -$fooTopic->addFlag(AMQP_EX_TYPE_FANOUT); -$psrContext->declareTopic($fooTopic); - -// to remove topic use delete topic method -//$psrContext->deleteTopic($fooTopic); -``` - -## Declare queue. - -Declare queue operation creates a queue on a broker side. - -```php -createQueue('foo'); -$fooQueue->addFlag(AMQP_DURABLE); -$psrContext->declareQueue($fooQueue); - -// to remove topic use delete queue method -//$psrContext->deleteQueue($fooQueue); -``` - -## Bind queue to topic - -Connects a queue to the topic. So messages from that topic comes to the queue and could be processed. - -```php -bind($fooTopic, $fooQueue); -``` - -## Send message to topic - -```php -createMessage('Hello world!'); - -$psrContext->createProducer()->send($fooTopic, $message); -``` - -## Send message to queue - -```php -createMessage('Hello world!'); - -$psrContext->createProducer()->send($fooQueue, $message); -``` - -## Consume message: - -```php -createConsumer($fooQueue); - -$message = $consumer->receive(); - -// process a message - -$consumer->acknowledge($message); -// $consumer->reject($message); -``` - -[back to index](index.md) \ No newline at end of file diff --git a/docs/async_event_dispatcher/quick_tour.md b/docs/async_event_dispatcher/quick_tour.md new file mode 100644 index 000000000..c9eb0e0a9 --- /dev/null +++ b/docs/async_event_dispatcher/quick_tour.md @@ -0,0 +1,118 @@ +--- +layout: default +nav_exclude: true +--- +{% include support.md %} + +# Async event dispatcher (Symfony) + +The doc shows how you can setup async event dispatching in plain PHP. +If you are looking for the ways to use it in Symfony application [read this post instead](../bundle/async_events.md) + +* [Installation](#installation) +* [Configuration](#configuration) +* [Dispatch event](#dispatch-event) +* [Process async events](#process-async-events) + +## Installation + +You need the async dispatcher library and a one of [the supported transports](../transport) + +```bash +$ composer require enqueue/async-event-dispatcher enqueue/fs +``` + +## Configuration + +```php +createContext(); +$eventQueue = $context->createQueue('symfony_events'); + +$registry = new SimpleRegistry( + ['the_event' => 'default'], + ['default' => new PhpSerializerEventTransformer($context)] +); + +$asyncListener = new AsyncListener($context, $registry, $eventQueue); + +$dispatcher = new EventDispatcher(); + +// the listener sends even as a message through MQ +$dispatcher->addListener('the_event', $asyncListener); + +$asyncDispatcher = new AsyncEventDispatcher($dispatcher, $asyncListener); + +// the listener is executed on consumer side. +$asyncDispatcher->addListener('the_event', function() { +}); + +$asyncProcessor = new AsyncProcessor($registry, $asyncDispatcher); +``` + +## Dispatch event + +```php +dispatch('the_event', new GenericEvent('theSubject')); +``` + +## Process async events + +```php +createConsumer($eventQueue); + +while (true) { + if ($message = $consumer->receive(5000)) { + $result = $asyncProcessor->process($message, $context); + + switch ((string) $result) { + case Processor::ACK: + $consumer->acknowledge($message); + break; + case Processor::REJECT: + $consumer->reject($message); + break; + case Processor::REQUEUE: + $consumer->reject($message, true); + break; + default: + throw new \LogicException('Result is not supported'); + } + } +} +``` + +[back to index](../index.md) diff --git a/docs/bundle/async_commands.md b/docs/bundle/async_commands.md new file mode 100644 index 000000000..b099c0b9c --- /dev/null +++ b/docs/bundle/async_commands.md @@ -0,0 +1,77 @@ +--- +layout: default +parent: "Symfony bundle" +title: Async commands +nav_order: 7 +--- +{% include support.md %} + +# Async commands + +## Installation + +```bash +$ composer require enqueue/async-command:0.9.x-dev +``` + +## Configuration + +```yaml +# config/packages/enqueue_async_commands.yaml + +enqueue: + default: + async_commands: + enabled: true + timeout: 60 + command_name: ~ + queue_name: ~ +``` + +## Usage + +```php +get(ProducerInterface::class); + +$cmd = new RunCommand('debug:container', ['--tag=form.type']); +$producer->sendCommand(Commands::RUN_COMMAND, $cmd); +``` + +optionally you can get a command execution result: + +```php +get(ProducerInterface::class); + +$promise = $producer->sendCommand(Commands::RUN_COMMAND, new RunCommand('debug:container'), true); + +// do other stuff. + +if ($replyMessage = $promise->receive(5000)) { + $result = CommandResult::jsonUnserialize($replyMessage->getBody()); + + echo $result->getOutput(); +} +``` + +[back to index](index.md) diff --git a/docs/bundle/async_events.md b/docs/bundle/async_events.md new file mode 100644 index 000000000..d2d256146 --- /dev/null +++ b/docs/bundle/async_events.md @@ -0,0 +1,176 @@ +--- +layout: default +parent: "Symfony bundle" +title: Async events +nav_order: 6 +--- +{% include support.md %} + +# Async events + +The EnqueueBundle allows you to dispatch events asynchronously. +Behind the scene it replaces your listener with one that sends a message to MQ. +The message contains the event object. +The consumer, once it receives the message, restores the event and dispatches it to only async listeners. + +Async listeners benefits: + +* Reduces response time. Work is deferred to consumer processes. +* Better fault tolerance. Bugs in async listener do not affect user. Messages will wait till you fix bugs. +* Better scaling. Add more consumers to meet the load. + +_**Note**: Prior to Symfony 3.0, events contain `eventDispatcher` and the default php serializer transformer is unable to serialize the object. A transformer should be registered for every async event. Read the [event transformer](#event-transformer)._ + +## Configuration + +Symfony events are currently processed synchronously, enabling the async configuration for EnqueueBundle causes tagged listeners to defer action to a consumer asynchronously. +If you already [installed the bundle](quick_tour.md#install), then enable `async_events`. + +```yaml +# app/config/config.yml + +enqueue: + default: + async_events: + enabled: true + # if you'd like to send send messages onTerminate use spool_producer (it further reduces response time): + # spool_producer: true +``` + +## Usage + +To make your listener async you have add `async: true` and `dispatcher: 'enqueue.events.event_dispatcher'` attributes to the tag `kernel.event_listener`, like this: + +```yaml +# app/config/config.yml + +services: + acme.foo_listener: + class: 'AcmeBundle\Listener\FooListener' + tags: + - { name: 'kernel.event_listener', async: true, event: 'foo', method: 'onEvent', dispatcher: 'enqueue.events.event_dispatcher' } +``` + +or to `kernel.event_subscriber`: + +```yaml +# app/config/config.yml + +services: + test_async_subscriber: + class: 'AcmeBundle\Listener\TestAsyncSubscriber' + tags: + - { name: 'kernel.event_subscriber', async: true, dispatcher: 'enqueue.events.event_dispatcher' } +``` + +That's basically it. The rest of the doc describes advanced features. + +## Advanced Usage. + +You can also add an async listener directly and register a custom message processor for it: + +```yaml +# app/config/config.yml + +services: + acme.async_foo_listener: + class: 'Enqueue\AsyncEventDispatcher\AsyncListener' + public: false + arguments: ['@enqueue.transport.default.context', '@enqueue.events.registry', 'a_queue_name'] + tags: + - { name: 'kernel.event_listener', event: 'foo', method: 'onEvent', dispatcher: 'enqueue.events.event_dispatcher' } +``` + + +## Event transformer + +The bundle uses [php serializer](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue-bundle/Events/PhpSerializerEventTransformer.php) transformer by default to pass events through MQ. +You can write a transformer for each event type by implementing the `Enqueue\AsyncEventDispatcher\EventTransformer` interface. +Consider the next example. It shows how to send an event that contains Doctrine entity as a subject + +```php +doctrine = $doctrine; + } + + /** + * {@inheritdoc} + * + * @param GenericEvent $event + */ + public function toMessage($eventName, Event $event = null) + { + $entity = $event->getSubject(); + $entityClass = get_class($entity); + + $manager = $this->doctrine->getManagerForClass($entityClass); + $meta = $manager->getClassMetadata($entityClass); + + $id = $meta->getIdentifierValues($entity); + + $message = new Message(); + $message->setBody([ + 'entityClass' => $entityClass, + 'entityId' => $id, + 'arguments' => $event->getArguments() + ]); + + return $message; + } + + /** + * {@inheritdoc} + */ + public function toEvent($eventName, QueueMessage $message) + { + $data = JSON::decode($message->getBody()); + + $entityClass = $data['entityClass']; + + $manager = $this->doctrine->getManagerForClass($entityClass); + if (false == $entity = $manager->find($entityClass, $data['entityId'])) { + return Result::reject('The entity could not be found.'); + } + + return new GenericEvent($entity, $data['arguments']); + } +} +``` + +and register it: + +```yaml +# app/config/config.yml + +services: + acme.foo_event_transformer: + class: 'AcmeBundle\Listener\FooEventTransformer' + arguments: ['@doctrine'] + tags: + - {name: 'enqueue.event_transformer', eventName: 'foo' } +``` + +The `eventName` attribute accepts a regexp. You can do next `eventName: '/foo\..*?/'`. +It uses this transformer for all event with the name beginning with `foo.` + +[back to index](index.md) diff --git a/docs/bundle/cli_commands.md b/docs/bundle/cli_commands.md index 5f636128a..6c383f8c6 100644 --- a/docs/bundle/cli_commands.md +++ b/docs/bundle/cli_commands.md @@ -1,10 +1,21 @@ +--- +layout: default +parent: "Symfony bundle" +title: CLI commands +nav_order: 3 +--- +{% include support.md %} + # Cli commands +The EnqueueBundle provides several commands. +The most useful one `enqueue:consume` connects to the broker and process the messages. +Other commands could be useful during debugging (like `enqueue:topics`) or deployment (like `enqueue:setup-broker`). + * [enqueue:consume](#enqueueconsume) * [enqueue:produce](#enqueueproduce) * [enqueue:setup-broker](#enqueuesetup-broker) -* [enqueue:queues](#enqueuequeues) -* [enqueue:topics](#enqueuetopics) +* [enqueue:routes](#enqueueroutes) * [enqueue:transport:consume](#enqueuetransportconsume) ## enqueue:consume @@ -16,22 +27,27 @@ Usage: enq:c Arguments: - client-queue-names Queues to consume messages from + client-queue-names Queues to consume messages from Options: - --message-limit=MESSAGE-LIMIT Consume n messages and exit - --time-limit=TIME-LIMIT Consume messages during this time - --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB - --setup-broker Creates queues, topics, exchanges, binding etc on broker side. - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + --message-limit=MESSAGE-LIMIT Consume n messages and exit + --time-limit=TIME-LIMIT Consume messages during this time + --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB + --niceness=NICENESS Set process niceness + --setup-broker Creates queues, topics, exchanges, binding etc on broker side. + --receive-timeout=RECEIVE-TIMEOUT The time in milliseconds queue consumer waits for a message. + --logger[=LOGGER] A logger to be used. Could be "default", "null", "stdout". [default: "default"] + --skip[=SKIP] Queues to skip consumption of messages from (multiple values allowed) + -c, --client[=CLIENT] The client to consume messages from. [default: "default"] + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "test"] + --no-debug Switches off debug mode. + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: A client's worker that processes messages. By default it connects to default queue. It select an appropriate message processor based on a message headers @@ -42,26 +58,28 @@ Help: ``` ./bin/console enqueue:produce --help Usage: - enqueue:produce - enq:p + enqueue:produce [options] [--] Arguments: - topic A topic to send message to - message A message to send + message A message Options: - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + -c, --client[=CLIENT] The client to send messages to. [default: "default"] + --topic[=TOPIC] The topic to send a message to + --command[=COMMAND] The command to send a message to + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "test"] + --no-debug Switches off debug mode. + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: - A command to send a message to topic + Sends an event to the topic + ``` ## enqueue:setup-broker @@ -69,99 +87,81 @@ Help: ``` ./bin/console enqueue:setup-broker --help Usage: - enqueue:setup-broker + enqueue:setup-broker [options] enq:sb Options: - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + -c, --client[=CLIENT] The client to consume messages from. [default: "default"] + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "test"] + --no-debug Switches off debug mode. + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: - Creates all required queues + Setup broker. Configure the broker, creates queues, topics and so on. ``` -## enqueue:queues +## enqueue:routes ``` -/bin/console enqueue:queues --help +./bin/console enqueue:routes --help Usage: - enqueue:queues - enq:m:q - debug:enqueue:queues + enqueue:routes [options] + debug:enqueue:routes Options: - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + --show-route-options Adds ability to hide options. + -c, --client[=CLIENT] The client to consume messages from. [default: "default"] + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "test"] + --no-debug Switches off debug mode. + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: - A command shows all available queues and some information about them. -``` - -## enqueue:topics - -``` -./bin/console enqueue:topics --help -Usage: - enqueue:topics - enq:m:t - debug:enqueue:topics - -Options: - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -Help: - A command shows all available topics and some information about them. + A command lists all registered routes. ``` ## enqueue:transport:consume - + ``` -./bin/console enqueue:transport:consume --help +./bin/console enqueue:transport:consume --help Usage: - enqueue:transport:consume [options] [--] + enqueue:transport:consume [options] [--] []... Arguments: - queue Queues to consume from - processor-service A message processor service + processor A message processor. + queues A queue to consume from Options: - --message-limit=MESSAGE-LIMIT Consume n messages and exit - --time-limit=TIME-LIMIT Consume messages during this time - --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The environment name [default: "dev"] - --no-debug Switches off debug mode - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + --message-limit=MESSAGE-LIMIT Consume n messages and exit + --time-limit=TIME-LIMIT Consume messages during this time + --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB + --niceness=NICENESS Set process niceness + --receive-timeout=RECEIVE-TIMEOUT The time in milliseconds queue consumer waits for a message. + --logger[=LOGGER] A logger to be used. Could be "default", "null", "stdout". [default: "default"] + -t, --transport[=TRANSPORT] The transport to consume messages from. [default: "default"] + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "test"] + --no-debug Switches off debug mode. + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: A worker that consumes message from a broker. To use this broker you have to explicitly set a queue to consume from and a message processor service ``` -[back to index](../index.md) \ No newline at end of file +[back to index](index.md) diff --git a/docs/bundle/config_reference.md b/docs/bundle/config_reference.md index e98af73ed..042b93cfa 100644 --- a/docs/bundle/config_reference.md +++ b/docs/bundle/config_reference.md @@ -1,105 +1,83 @@ +--- +layout: default +parent: "Symfony bundle" +title: Config reference +nav_order: 2 +--- +{% include support.md %} + # Config reference +You can get this info by running `./bin/console config:dump-reference enqueue` command. + ```yaml # Default configuration for extension with alias: "enqueue" enqueue: - transport: # Required - default: - alias: ~ # Required - null: [] - stomp: - host: localhost - port: 61613 - login: guest - password: guest - vhost: / - sync: true - connection_timeout: 1 - buffer_size: 1000 - rabbitmq_stomp: - host: localhost - port: 61613 - login: guest - password: guest - vhost: / - sync: true - connection_timeout: 1 - buffer_size: 1000 - - # The option tells whether RabbitMQ broker has management plugin installed or not - management_plugin_installed: false - management_plugin_port: 15672 - - # The option tells whether RabbitMQ broker has delay plugin installed or not - delay_plugin_installed: false - amqp: - - # The host to connect too. Note: Max 1024 characters - host: localhost - - # Port on the host. - port: 5672 - - # The login name to use. Note: Max 128 characters. - login: guest - - # Password. Note: Max 128 characters. - password: guest - - # The virtual host on the host. Note: Max 128 characters. - vhost: / - - # Connection timeout. Note: 0 or greater seconds. May be fractional. - connect_timeout: ~ - - # Timeout in for income activity. Note: 0 or greater seconds. May be fractional. - read_timeout: ~ - - # Timeout in for outcome activity. Note: 0 or greater seconds. May be fractional. - write_timeout: ~ - persisted: false - rabbitmq: - - # The host to connect too. Note: Max 1024 characters - host: localhost - - # Port on the host. - port: 5672 - - # The login name to use. Note: Max 128 characters. - login: guest - - # Password. Note: Max 128 characters. - password: guest - - # The virtual host on the host. Note: Max 128 characters. - vhost: / - - # Connection timeout. Note: 0 or greater seconds. May be fractional. - connect_timeout: ~ - - # Timeout in for income activity. Note: 0 or greater seconds. May be fractional. - read_timeout: ~ - - # Timeout in for outcome activity. Note: 0 or greater seconds. May be fractional. - write_timeout: ~ - persisted: false - - # The option tells whether RabbitMQ broker has delay plugin installed or not - delay_plugin_installed: false - client: - traceable_producer: false - prefix: enqueue - app_name: app - router_topic: router - router_queue: default - router_processor: enqueue.client.router_processor - default_processor_queue: default - redelivered_delay_time: 0 - job: false - extensions: - doctrine_ping_connection_extension: false - doctrine_clear_identity_map_extension: false + + # Prototype + key: + + # The transport option could accept a string DSN, an array with DSN key, or null. It accept extra options. To find out what option you can set, look at connection factory constructor docblock. + transport: # Required + + # The MQ broker DSN. These schemes are supported: "file", "amqp", "amqps", "db2", "ibm-db2", "mssql", "sqlsrv", "mysql", "mysql2", "pgsql", "postgres", "sqlite", "sqlite3", "null", "gearman", "beanstalk", "kafka", "rdkafka", "redis", "rediss", "stomp", "sqs", "gps", "mongodb", "wamp", "ws", to use these "file", "amqp", "amqps", "db2", "ibm-db2", "mssql", "sqlsrv", "mysql", "mysql2", "pgsql", "postgres", "sqlite", "sqlite3", "null", "gearman", "beanstalk", "kafka", "rdkafka", "redis", "rediss", "stomp", "sqs", "gps", "mongodb", "wamp", "ws" you have to install a package. + dsn: ~ # Required + + # The connection factory class should implement "Interop\Queue\ConnectionFactory" interface + connection_factory_class: ~ + + # The factory class should implement "Enqueue\ConnectionFactoryFactoryInterface" interface + factory_service: ~ + + # The factory service should be a class that implements "Enqueue\ConnectionFactoryFactoryInterface" interface + factory_class: ~ + consumption: + + # the time in milliseconds queue consumer waits for a message (100 ms by default) + receive_timeout: 10000 + client: + traceable_producer: true + prefix: enqueue + separator: . + app_name: app + router_topic: default + router_queue: default + router_processor: null + redelivered_delay_time: 0 + default_queue: default + + # The array contains driver specific options + driver_options: [] + + # The "monitoring" option could accept a string DSN, an array with DSN key, or null. It accept extra options. To find out what option you can set, look at stats storage constructor doc block. + monitoring: + + # The stats storage DSN. These schemes are supported: "wamp", "ws", "influxdb". + dsn: ~ # Required + + # The factory class should implement "Enqueue\Monitoring\StatsStorageFactory" interface + storage_factory_service: ~ + + # The factory service should be a class that implements "Enqueue\Monitoring\StatsStorageFactory" interface + storage_factory_class: ~ + async_commands: + enabled: false + timeout: 60 + command_name: ~ + queue_name: ~ + job: + enabled: false + default_mapping: true + async_events: + enabled: false + extensions: + doctrine_ping_connection_extension: false + doctrine_clear_identity_map_extension: false + doctrine_odm_clear_identity_map_extension: false + doctrine_closed_entity_manager_extension: false + reset_services_extension: false + signal_extension: true + reply_extension: true ``` -[back to index](../index.md) \ No newline at end of file +[back to index](index.md) diff --git a/docs/bundle/consumption_extension.md b/docs/bundle/consumption_extension.md index 38657c58f..b05c9f89e 100644 --- a/docs/bundle/consumption_extension.md +++ b/docs/bundle/consumption_extension.md @@ -1,6 +1,14 @@ +--- +layout: default +parent: "Symfony bundle" +title: Consumption extension +nav_order: 9 +--- +{% include support.md %} + # Consumption extension -Here, I show how you can crate a custom extension and register it. +Here, I show how you can create a custom extension and register it. Let's first create an extension itself: ```php @@ -8,20 +16,14 @@ Let's first create an extension itself: // src/AppBundle/Enqueue; namespace AppBundle\Enqueue; -use Enqueue\Consumption\ExtensionInterface; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; +use Enqueue\Consumption\Context\PostMessageReceived; -class CountProcessedMessagesExtension implements ExtensionInterface +class CountProcessedMessagesExtension implements PostMessageReceivedExtensionInterface { - use EmptyExtensionTrait; - private $processedMessages = 0; - - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + + public function onPostMessageReceived(PostMessageReceived $context): void { $this->processedMessages += 1; } @@ -38,4 +40,15 @@ services: - { name: 'enqueue.consumption.extension', priority: 10 } ``` -[back to index](../index.md) \ No newline at end of file +When using multiple enqueue instances, you can apply extension to +specific or all instances by providing an additional tag attribute: + +``` +services: + app.enqueue.count_processed_messages_extension: + class: 'AppBundle\Enqueue\CountProcessedMessagesExtension' + tags: + - { name: 'enqueue.consumption.extension', priority: 10, client: 'all' } +``` + +[back to index](index.md) diff --git a/docs/bundle/debugging.md b/docs/bundle/debugging.md new file mode 100644 index 000000000..6ae79b3a0 --- /dev/null +++ b/docs/bundle/debugging.md @@ -0,0 +1,80 @@ +--- +layout: default +parent: "Symfony bundle" +title: Debugging +nav_order: 11 +--- +{% include support.md %} + +# Debugging + +## Profiler + +It may be useful to see what messages were sent during a http request. +The bundle provides a collector for Symfony [profiler](http://symfony.com/doc/current/profiler.html). +The extension collects all sent messages + +To enable profiler + +```yaml +# app/config/config_dev.yml + +enqueue: + default: + client: + traceable_producer: true +``` + +Now suppose you have this code in an action: + +```php +get('enqueue.producer'); + + $producer->sendEvent('foo_topic', 'Hello world'); + + $producer->sendEvent('bar_topic', ['bar' => 'val']); + + $message = new Message(); + $message->setBody('baz'); + $producer->sendEvent('baz_topic', $message); + + // ... + } + +``` + +For this action you may see something like this in the profiler: + + ![Symfony profiler](../images/symfony_profiler.png) + +## Queues and topics available + +There are two console commands `./bin/console enqueue:queues` and `./bin/console enqueue:topics`. +They are here to help you to learn more about existing topics and queues. + +Here's the result: + +![Cli debug commands](../images/cli_debug_commands.png) + +## Consume command verbosity + +By default the commands `enqueue:consume` or `enqueue:transport:consume` does not output anything. +You can add `-vvv` to see more information. + +![Consume command verbosity](../images/consume_command_verbosity.png) + +[back to index](index.md) diff --git a/docs/bundle/debuging.md b/docs/bundle/debuging.md deleted file mode 100644 index c853bb3fc..000000000 --- a/docs/bundle/debuging.md +++ /dev/null @@ -1,71 +0,0 @@ -# Debugging - -## Profiler - -It may be useful to see what messages were sent during a http request. -The bundle provides a collector for Symfony [profiler](http://symfony.com/doc/current/profiler.html). -The extension collects all sent messages - -To enable profiler - -```yaml -# app/config/config_dev.yml - -enqueue: - client: - traceable_producer: true -``` - -Now suppose you have this code in an action: - -```php -get('enqueue.message_producer'); - - $producer->send('foo_topic', 'Hello world'); - - $producer->send('bar_topic', ['bar' => 'val']); - - $message = new Message(); - $message->setBody('baz'); - $producer->send('baz_topic', $message); - - // ... - } - -``` - -For this action you may see something like this in the profiler: - - ![Symfony profiler](../images/symfony_profiler.png) - -## Queues and topics available - -There are two console commands `./bin/console enqueue:queues` and `./bin/console enqueue:topics`. -They are here to help you to learn more about existing topics and queues. - -Here's the result: - -![Cli debug commands](../images/cli_debug_commands.png) - -## Consume command verbosity - -By default the commands `enqueu:conume` or `enqueue:transport:consume` does not output anything. -You can add `-vvv` to see more information. - -![Consume command verbosity](../images/consume_command_verbosity.png) - -[back to index](../index.md) \ No newline at end of file diff --git a/docs/bundle/functional_testing.md b/docs/bundle/functional_testing.md index 4d7f0cdd9..ca475a2e4 100644 --- a/docs/bundle/functional_testing.md +++ b/docs/bundle/functional_testing.md @@ -1,40 +1,48 @@ +--- +layout: default +parent: "Symfony bundle" +title: Functional testing +nav_order: 12 +--- +{% include support.md %} + # Functional testing In this chapter we give some advices on how to test message queue related logic. - + * [NULL transport](#null-transport) * [Traceable message producer](#traceable-message-producer) ## NULL transport -While testing the application you dont usually need to send real message to real broker. -Or even have a dependency on a MQ broker. -Here's the purpose of the NULL transport. -It simple do nothing when you ask it to send a message. -Pretty useful in tests. +While testing the application you don't usually need to send real message to real broker. +Or even have a dependency on a MQ broker. +Here's the purpose of the NULL transport. +It simple do nothing when you ask it to send a message. +Pretty useful in tests. Here's how you can configure it. ```yaml # app/config/config_test.yml enqueue: - transport: - default: 'null' - 'null': ~ - client: ~ + default: + transport: 'null:' + client: ~ ``` ## Traceable message producer -Imagine you have a service that internally sends a message and you have to find out was the message sent or not. -There is a solution for that. You have to enable traceable message producer in test environment. +Imagine you have a service `my_service` with a method `someMethod()` that internally sends a message and you have to find out was the message sent or not. +There is a solution for that. You have to enable traceable message producer in test environment. ```yaml # app/config/config_test.yml enqueue: - client: - traceable_producer: true + default: + client: + traceable_producer: true ``` If you did so, you can use its methods `getTraces`, `getTopicTraces` or `clearTraces`. Here's an example: @@ -42,39 +50,40 @@ If you did so, you can use its methods `getTraces`, `getTopicTraces` or `clearTr ```php client = static::createClient(); + $this->client = static::createClient(); } - + public function testMessageSentToFooTopic() { - $service = $this->client->getContainer()->get('a_service'); - - // the method calls inside $producer->send('fooTopic', 'messageBody'); - $service->do(); - - $traces = $this->getMessageProducer()->getTopicTraces('fooTopic'); - + // Use your own business logic here: + $service = $this->client->getContainer()->get('my_service'); + + // someMethod() is part of your business logic and is calling somewhere $producer->send('fooTopic', 'messageBody'); + $service->someMethod(); + + $traces = $this->getProducer()->getTopicTraces('fooTopic'); + $this->assertCount(1, $traces); $this->assertEquals('messageBody', $traces[0]['message']); } - + /** - * @return TraceableMessageProducer + * @return TraceableProducer */ - private function getMessageProducer() + private function getProducer() { - return $this->client->getContainer()->get('enqueue.client.message_producer'); + return $this->client->getContainer()->get(TraceableProducer::class); } } ``` -[back to index](../index.md) \ No newline at end of file +[back to index](index.md) diff --git a/docs/bundle/index.md b/docs/bundle/index.md new file mode 100644 index 000000000..abd08c663 --- /dev/null +++ b/docs/bundle/index.md @@ -0,0 +1,11 @@ +--- +layout: default +title: "Symfony bundle" +nav_order: 6 +has_children: true +permalink: /symfony +--- + +{:toc} + +[back to index](../index.md) diff --git a/docs/bundle/job_queue.md b/docs/bundle/job_queue.md index aded0212c..cd13ca3cd 100644 --- a/docs/bundle/job_queue.md +++ b/docs/bundle/job_queue.md @@ -1,33 +1,119 @@ +--- +layout: default +parent: "Symfony bundle" +title: Job queue +nav_order: 8 +--- +{% include support.md %} + # Jobs +Use jobs when your message flow has several steps(tasks) which run one after another. +Also jobs guaranty that job is unique i.e. you cant start new job with same name +until previous job has finished. + +* [Installation](#installation) * [Unique job](#unique-job) * [Sub jobs](#sub-jobs) +* [Dependent Job](#dependent-job) +## Installation -Use jobs when your message flow has several steps(tasks) which run one after another. -Also jobs guaranty that job is unique i.e. you cant start new job with same name -until previous job has finished. +The easiest way to install Enqueue's job queues is to by requiring a `enqueue/job-queue-pack` pack. +It installs installs everything you need. It also configures everything for you If you are on Symfony Flex. + +```bash +$ composer require enqueue/job-queue-pack=^0.8 +``` + +_**Note:** As long as you are on Symfony Flex you are done. If not, keep reading the installation chapter._ + +* Register installed bundles + +```php +jobRunner = $jobRunner; + } + public function process(Message $message, Context $context) { $data = JSON::decode($message->getBody()); @@ -44,24 +130,54 @@ class ReindexProcessor implements Processor return $result ? self::ACK : self::REJECT; } + + public static function getSubscribedCommand() + { + return 'search_reindex'; + } } ``` +* Register it + +```yaml +services: + app_queue_search_reindex_processor: + class: 'App\Queue\SearchReindexProcessor' + arguments: ['@Enqueue\JobQueue\JobRunner'] + tags: + - { name: 'enqueue.command_subscriber' } +``` + +* Schedule command + +```php +get(ProducerInterface::class); + +$producer->sendCommand('search_reindex'); +``` + ## Sub jobs -Run several sub jobs in parallel. +Run several sub jobs in parallel. The steps are the same as we described above. ```php createDelayed( $jobName, function (JobRunner $runner, Job $childJob) use ($entity) { - $this->producer->send('search:index:index-single-entity', [ + $this->producer->sendEvent('search:index:index-single-entity', [ 'entityId' => $entity->getId(), 'jobId' => $childJob->getId(), ]); @@ -104,7 +220,7 @@ class Step1Processor implements Processor } } -class Step2Processor implements Processor +class Step2Processor implements Processor { /** * @var JobRunner @@ -124,15 +240,16 @@ class Step2Processor implements Processor } ); - return $result ? Result::ACK : Result::REJECT; + return $result ? self::ACK : self::REJECT; } } ``` -###Dependent Job +## Dependent Job Use dependent job when your job flow has several steps but you want to send new message just after all steps are finished. +The steps are the same as we described above. ```php 'aCommand', + 'queue' => 'the-queue-name', + 'prefix_queue' => false, + 'exclusive' => true, + ]; + } +} +``` + +The service has to be tagged with `enqueue.command_subscriber` tag. + +# Register a custom processor + +You could register a processor that does not implement neither `CommandSubscriberInterface` not `TopicSubscriberInterface`. +There is a tag `enqueue.processor` for it. You must define either `topic` or `command` tag attribute. +It is possible to define a client you would like to register the processor to. By default, it is registered to default client (first configured or named `default` one ). + +```yaml +# src/AppBundle/Resources/services.yml + +services: + AppBundle\Async\SayHelloProcessor: + tags: + # registers as topic processor + - { name: 'enqueue.processor', topic: 'aTopic' } + # registers as command processor + - { name: 'enqueue.processor', command: 'aCommand' } + + # registers to no default client + - { name: 'enqueue.processor', command: 'aCommand', client: 'foo' } +``` + +The tag has some additional options: + +* queue +* prefix_queue +* processor +* exclusive + +You could add your own attributes. They will be accessible through `Route::getOption` later. + +# Register a transport processor + +If you want to use a processor with `enqueue:transport:consume` it should be tagged `enqueue.transport.processor`. +It is possible to define a transport you would like to register the processor to. By default, it is registered to default transport (first configured or named `default` one ). + +```yaml +# config/services.yml + +services: + App\Queue\SayHelloProcessor: + tags: + - { name: 'enqueue.transport.processor', processor: 'say_hello' } + + # registers to no default transport + - { name: 'enqueue.processor', transport: 'foo' } +``` + +The tag has some additional options: + +* processor + +Now you can run a command and tell it to consume from a given queue and process messages with given processor: + +```bash +$ ./bin/console enqueue:transport:consume say_hello foo_queue -vvv +``` + +[back to index](index.md) diff --git a/docs/bundle/message_producer.md b/docs/bundle/message_producer.md new file mode 100644 index 000000000..a4448bd7e --- /dev/null +++ b/docs/bundle/message_producer.md @@ -0,0 +1,96 @@ +--- +layout: default +parent: "Symfony bundle" +title: Message producer +nav_order: 4 +--- +{% include support.md %} + +# Message producer + +You can choose how to send messages either using a transport directly or with the client. +Transport gives you the access to all transport specific features so you can tune things where the client provides you with easy to use abstraction. + +## Transport + +```php +get('enqueue.transport.[transport_name].context'); + +$context->createProducer()->send( + $context->createQueue('a_queue'), + $context->createMessage('Hello there!') +); +``` + +## Client + +The client is shipped with two types of producers. The first one sends messages immediately +where another one (it is called spool producer) collects them in memory and sends them `onTerminate` event (the response is already sent). + +The producer has two types on send methods: + +* `sendEvent` - Message is sent to topic and many consumers can subscribe to it. It is "fire and forget" strategy. The event could be sent to "message bus" to other applications. +* `sendCommand` - Message is to ONE exact consumer. It could be used as "fire and forget" or as RPC. The command message is always sent in scope of current application. + +### Send event + +```php +get(ProducerInterface::class); + +// message is being sent right now +$producer->sendEvent('a_topic', 'Hello there!'); + + +/** @var \Enqueue\Client\SpoolProducer $spoolProducer */ +$spoolProducer = $container->get(SpoolProducer::class); + +// message is being sent on console.terminate or kernel.terminate event +$spoolProducer->sendEvent('a_topic', 'Hello there!'); + +// you could send queued messages manually by calling flush method +$spoolProducer->flush(); +``` + +### Send command + +```php +get(ProducerInterface::class); + +// message is being sent right now, we use it as RPC +$promise = $producer->sendCommand('a_processor_name', 'Hello there!', $needReply = true); + +$replyMessage = $promise->receive(); + + +/** @var \Enqueue\Client\SpoolProducer $spoolProducer */ +$spoolProducer = $container->get(SpoolProducer::class); + +// message is being sent on console.terminate or kernel.terminate event +$spoolProducer->sendCommand('a_processor_name', 'Hello there!'); + +// you could send queued messages manually by calling flush method +$spoolProducer->flush(); +``` + +[back to index](index.md) diff --git a/docs/bundle/production_settings.md b/docs/bundle/production_settings.md index 6b549e58c..7f07abed9 100644 --- a/docs/bundle/production_settings.md +++ b/docs/bundle/production_settings.md @@ -1,13 +1,21 @@ +--- +layout: default +parent: "Symfony bundle" +title: Production settings +nav_order: 10 +--- +{% include support.md %} + # Production settings ## Supervisord -As you may read in [quick tour](quick_tour.md) you have to run `enqueue:consume` in order to process messages +As you may read in [quick tour](quick_tour.md) you have to run `enqueue:consume` in order to process messages The php process is not designed to work for a long time. So it has to quit periodically. -Or, the command may exit because of error or exception. +Or, the command may exit because of error or exception. Something has to bring it back and continue message consumption. -We advise you to use [Supervisord](http://supervisord.org/) for that. -It starts processes and keep an eye on them while they are working. +We advise you to use [Supervisord](http://supervisord.org/) for that. +It starts processes and keep an eye on them while they are working. Here an example of supervisord configuration. @@ -15,7 +23,7 @@ It runs four instances of `enqueue:consume` command. ```ini [program:pf_message_consumer] -command=/path/to/app/console --env=prod --no-debug --time-limit="now + 5 minutes" enqueue:consume +command=/path/to/bin/console --env=prod --no-debug --time-limit="now + 5 minutes" enqueue:consume process_name=%(program_name)s_%(process_num)02d numprocs=4 autostart=true @@ -27,4 +35,4 @@ redirect_stderr=true _**Note**: Pay attention to `--time-limit` it tells the command to exit after 5 minutes._ -[back to index](../index.md) \ No newline at end of file +[back to index](index.md) diff --git a/docs/bundle/quick_tour.md b/docs/bundle/quick_tour.md index 661cf5fa0..790c570cb 100644 --- a/docs/bundle/quick_tour.md +++ b/docs/bundle/quick_tour.md @@ -1,52 +1,119 @@ +--- +layout: default +parent: "Symfony bundle" +title: Quick tour +nav_order: 1 +--- +{% include support.md %} + # EnqueueBundle. Quick tour. -The bundle integrates enqueue library. +The [EnqueueBundle](https://github.com/php-enqueue/enqueue-bundle) integrates enqueue library. It adds easy to use [configuration layer](config_reference.md), register services, adds handy [cli commands](cli_commands.md). ## Install ```bash -$ composer require enqueue/enqueue-bundle enqueue/amqp-ext +$ composer require enqueue/enqueue-bundle enqueue/fs +``` + +_**Note**: You could various other [transports](https://github.com/php-enqueue/enqueue-dev/tree/master/docs/transport)._ + +_**Note**: If you are looking for a way to migrate from `php-amqplib/rabbitmq-bundle` read this [article](https://blog.forma-pro.com/the-how-and-why-of-the-migration-from-rabbitmqbundle-to-enqueuebundle-6c4054135e2b)._ + +## Enable the Bundle + +Then, enable the bundle by adding `new Enqueue\Bundle\EnqueueBundle()` to the bundles array of the registerBundles method in your project's `app/AppKernel.php` file: + +```php +get(ProducerInterface::class); + +// If you want a different producer than default (for example the other specified in sample above) then use +// $producer = $container->get('enqueue.client.some_other_transport.producer'); -/** @var MessageProducer $messageProducer **/ -$messageProducer = $container->get('enqueue.message_producer'); +// send event to many consumers +$producer->sendEvent('aFooTopic', 'Something has happened'); +// You can also pass an instance of Enqueue\Client\Message as second argument if you need more flexibility. +$properties = []; +$headers = []; +$message = new Message('Message body', $properties, $headers); +$producer->sendEvent('aBarTopic', $message); -$messageProducer->send('aFooTopic', 'Something has happened'); +// send command to ONE consumer +$producer->sendCommand('aProcessorName', 'Something has happened'); ``` -To consume messages you have to first create a message processor: +To consume messages you have to first create a message processor. + +Example below shows how to create a Processor that will receive messages from `aFooTopic` topic (and only that one). +It assumes that you're using default Symfony services configuration and this class is +[autoconfigured](https://symfony.com/doc/current/service_container.html#the-autoconfigure-option). Otherwise you'll +have to tag it manually. This is especially true if you're using multiple transports: if left autoconfigured, processor +will be attached to the default transport only. + +Note: Topic in enqueue and topic on some transports (for example Kafka) are two different things. ```php getTimestamp()) { + $message->setTimestamp(time()); + } + } + + public function onPostSend($topic, Message $message) + { + + } +} +``` + +## Symfony + +To use the extension in Symfony, you have to register it as a container service with a special tag. + +```yaml +# config/services.yaml + +services: + timestamp_message_extension: + class: Acme\TimestampMessageExtension + tags: + - { name: 'enqueue.client.extension' } +``` + +You can add `priority` attribute with a number. The higher value you set the earlier the extension is called. + +[back to index](../index.md) diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 000000000..aa222138f --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Client +nav_order: 4 +has_children: true +permalink: /client +--- + +{:toc} diff --git a/docs/client/message_bus.md b/docs/client/message_bus.md index 24d29ccd0..0a5a3aa1c 100644 --- a/docs/client/message_bus.md +++ b/docs/client/message_bus.md @@ -1,16 +1,24 @@ +--- +layout: default +parent: Client +title: Message bus +nav_order: 4 +--- +{% include support.md %} + # Client. Message bus - + Here's a description of message bus from [Enterprise Integration Patterns](http://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageBus.html) > A Message Bus is a combination of a common data model, a common command set, and a messaging infrastructure to allow different systems to communicate through a shared set of interfaces. -If all your applications built on top of Enqueue Client you have to only make sure they send message to a shared topic. +If all your applications built on top of Enqueue Client you have to only make sure they send message to a shared topic. The rest is done under the hood. If you'd like to connect another application (written on Python for example ) you have to follow these rules: -* An application defines its own queue that is connected to the topic as fanout. -* A message sent to message bus topic must have a header `enqueue.topic_name`. +* An application defines its own queue that is connected to the topic as fanout. +* A message sent to message bus topic must have a header `enqueue.topic_name`. * Once a message is received it could be routed internally. `enqueue.topic_name` header could be used for that. [back to index](../index.md) diff --git a/docs/client/message_examples.md b/docs/client/message_examples.md index f39c1c027..f43ff6d46 100644 --- a/docs/client/message_examples.md +++ b/docs/client/message_examples.md @@ -1,9 +1,41 @@ +--- +layout: default +parent: Client +title: Message examples +nav_order: 2 +--- +{% include support.md %} + # Client. Message examples - -## Delay + +* [Scope](#scope) +* [Delay](#delay) +* [Expiration (TTL)](#expiration-ttl) +* [Priority](#priority) +* [Timestamp, Content type, Message id](#timestamp-content-type-message-id) + +## Scope + +There are two types possible scopes: `Message:SCOPE_MESSAGE_BUS` and `Message::SCOPE_APP`. +The first one instructs the client send messages (if driver supports) to the message bus so other apps can consume those messages. +The second in turns limits the message to the application that sent it. No other apps could receive it. + +```php +setScope(Message::SCOPE_MESSAGE_BUS); + +/** @var \Enqueue\Client\ProducerInterface $producer */ +$producer->sendEvent('aTopic', $message); +``` + +## Delay Message sent with a delay set is processed after the delay time exceed. -Some brokers may not support it from scratch. +Some brokers may not support it from scratch. In order to use delay feature with [RabbitMQ](https://www.rabbitmq.com/) you have to install a [delay plugin](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange). ```php @@ -14,13 +46,13 @@ use Enqueue\Client\Message; $message = new Message(); $message->setDelay(60); // seconds -/** @var \Enqueue\Client\MessageProducerInterface $producer */ -$producer->send('aTopic', $message); +/** @var \Enqueue\Client\ProducerInterface $producer */ +$producer->sendEvent('aTopic', $message); ``` ## Expiration (TTL) -The message may have an expiration or TTL (time to live). +The message may have an expiration or TTL (time to live). The message is removed from the queue if the expiration exceeded but the message has not been consumed. For example it make sense to send a forgot password email within first few minutes, nobody needs it in an hour. @@ -32,15 +64,20 @@ use Enqueue\Client\Message; $message = new Message(); $message->setExpire(60); // seconds -/** @var \Enqueue\Client\MessageProducerInterface $producer */ -$producer->send('aTopic', $message); +/** @var \Enqueue\Client\ProducerInterface $producer */ +$producer->sendEvent('aTopic', $message); ``` -## Priority +## Priority You can set a priority If you want a message to be processed quicker than other messages in the queue. -Client defines five priority constants: `MessagePriority::VERY_LOW`, `MessagePriority::LOW`, `MessagePriority::NORMAL`, `MessagePriority::HIGH`, `MessagePriority::VERY_HIGH`. -The `MessagePriority::NORMAL` is default priority. +Client defines five priority constants: + +* `MessagePriority::VERY_LOW` +* `MessagePriority::LOW` +* `MessagePriority::NORMAL` (**default**) +* `MessagePriority::HIGH` +* `MessagePriority::VERY_HIGH` ```php setPriority(MessagePriority::HIGH); -/** @var \Enqueue\Client\MessageProducerInterface $producer */ -$producer->send('aTopic', $message); +/** @var \Enqueue\Client\ProducerInterface $producer */ +$producer->sendEvent('aTopic', $message); ``` ## Timestamp, Content type, Message id -Those are self describing things. -Usually they are set by Client so you dont have to worry about them. +Those are self describing things. +Usually they are set by Client so you don't have to worry about them. If you do not like what Client set you can always set custom values: - + ```php setMessageId('aCustomMessageId'); $message->setTimestamp(time()); $message->setContentType('text/plain'); -/** @var \Enqueue\Client\MessageProducerInterface $producer */ -$producer->send('aTopic', $message); +/** @var \Enqueue\Client\ProducerInterface $producer */ +$producer->sendEvent('aTopic', $message); ``` [back to index](../index.md) diff --git a/docs/client/quick_tour.md b/docs/client/quick_tour.md new file mode 100644 index 000000000..84d6986e8 --- /dev/null +++ b/docs/client/quick_tour.md @@ -0,0 +1,151 @@ +--- +layout: default +parent: Client +title: Quick tour +nav_order: 1 +--- +{% include support.md %} + +# Simple client. Quick tour. + +The simple client library takes Enqueue client classes and Symfony components and makes an easy to use client facade. +It reduces the boiler plate code you have to write to start using the Enqueue client features. + +* [Install](#install) +* [Configure](#configure) +* [Producer message](#produce-message) +* [Consume messages](#consume-messages) + +## Install + +```bash +$ composer require enqueue/simple-client enqueue/amqp-ext +``` + +## Configure + +The code below shows how to use simple client with AMQP transport. There are other [supported brokers](supported_brokers.md). + +```php +setupBroker(); + +$client->sendEvent('user_updated', 'aMessageData'); + +// or an array + +$client->sendEvent('order_price_calculated', ['foo', 'bar']); + +// or an json serializable object +$client->sendEvent('user_activated', new class() implements \JsonSerializable { + public function jsonSerialize() { + return ['foo', 'bar']; + } +}); +``` + +Send command examples: + +```php +setupBroker(); + +// accepts same type of arguments as sendEvent method +$client->sendCommand('calculate_statistics', 'aMessageData'); + +$reply = $client->sendCommand('build_category_tree', 'aMessageData', true); + +$replyMessage = $reply->receive(5000); // wait for reply for 5 seconds + +$replyMessage->getBody(); +``` + +## Consume messages + +```php +bindTopic('a_bar_topic', function(Message $psrMessage) { + // processing logic here + + return Processor::ACK; +}); + +$client->consume(); +``` + +## Cli commands + +```php +#!/usr/bin/env php +add(new SimpleSetupBrokerCommand($client->getDriver())); +$application->add(new SimpleRoutesCommand($client->getDriver())); +$application->add(new SimpleProduceCommand($client->getProducer())); +$application->add(new SimpleConsumeCommand( + $client->getQueueConsumer(), + $client->getDriver(), + $client->getDelegateProcessor() +)); + +$application->run(); +``` + +and run to see what is there: + +```bash +$ php bin/enqueue.php +``` + +or consume messages + +```bash +$ php bin/enqueue.php enqueue:consume -vvv --setup-broker +``` + +[back to index](../index.md) diff --git a/docs/client/rpc_call.md b/docs/client/rpc_call.md new file mode 100644 index 000000000..cfdd6ce46 --- /dev/null +++ b/docs/client/rpc_call.md @@ -0,0 +1,86 @@ +--- +layout: default +parent: Client +title: RPC call +nav_order: 5 +--- +{% include support.md %} + +# Client. RPC call + +The client's [quick tour](quick_tour.md) describes how to get the client object. +Here we'll show you how to use Enqueue Client to perform a [RPC call](https://en.wikipedia.org/wiki/Remote_procedure_call). +You can do it by defining a command which returns something. + +## The consumer side + +On the consumer side we have to register a command processor which computes the result and send it back to the sender. +Pay attention that you have to add reply extension. It won't work without it. + +Of course it is possible to implement rpc server side based on transport classes only. That would require a bit more work to do. + +```php +bindCommand('square', function (Message $message, Context $context) use (&$requestMessage) { + $number = (int) $message->getBody(); + + return Result::reply($context->createMessage($number ^ 2)); +}); + +$client->consume(new ChainExtension([new ReplyExtension()])); +``` + +[back to index](../index.md) + +## The sender side + +On the sender's side we need a client which send a command and wait for reply messages. + +```php +sendCommand('square', 5, true)->receive(5000 /* 5 sec */)->getBody(); +``` + +You can perform several requests asynchronously with `sendCommand` and ask for replays later. + +```php +sendCommand('square', 5, true); +$promises[] = $client->sendCommand('square', 10, true); +$promises[] = $client->sendCommand('square', 7, true); +$promises[] = $client->sendCommand('square', 12, true); + +$replyMessages = []; +while ($promises) { + foreach ($promises as $index => $promise) { + if ($replyMessage = $promise->receiveNoWait()) { + $replyMessages[$index] = $replyMessage; + + unset($promises[$index]); + } + } +} +``` diff --git a/docs/client/supported_brokers.md b/docs/client/supported_brokers.md index 39c92c215..076e3b013 100644 --- a/docs/client/supported_brokers.md +++ b/docs/client/supported_brokers.md @@ -1,17 +1,53 @@ -# Client. Supported brokers +--- +layout: default +parent: Client +title: Supported brokers +nav_order: 3 +--- +{% include support.md %} -Here's the list of protocols and Client features supported by them +# Client Supported brokers -| Protocol | Priority | Delay | Expiration | Setup broker | Message bus | -|:--------------:|:--------:|:--------:|:----------:|:------------:|:-----------:| -| AMQP | No | No | Yes | Yes | Yes | -| RabbitMQ AMQP | Yes | Yes* | Yes | Yes | Yes | -| STOMP | No | No | Yes | No | Yes** | -| RabbitMQ STOMP | Yes | Yes* | Yes | Yes*** | Yes** | +Here's the list of transports supported by Enqueue Client: +| Transport | Package | DSN | +|:---------------------:|:----------------------------------------------------------:|:-------------------------------:| +| AMQP, RabbitMQ | [enqueue/amqp-ext](../transport/amqp.md) | amqp: amqp+ext: | +| AMQP, RabbitMQ | [enqueue/amqp-bunny](../transport/amqp_bunny.md) | amqp: amqp+bunny: | +| AMQP, RabbitMQ | [enqueue/amqp-lib](../transport/amqp_lib.md) | amqp: amqp+lib: amqp+rabbitmq: | +| Doctrine DBAL | [enqueue/dbal](../transport/dbal.md) | mysql: pgsql: pdo_pgsql etc | +| Filesystem | [enqueue/fs](../transport/fs.md) | file:///foo/bar | +| Gearman | [enqueue/gearman](../transport/gearman.md) | gearman: | +| GPS, Google PubSub | [enqueue/gps](../transport/gps.md) | gps: | +| Kafka | [enqueue/rdkafka](../transport/kafka.md) | kafka: | +| MongoDB | [enqueue/mongodb](../transport/mongodb.md) | mongodb: | +| Null | [enqueue/null](../transport/null.md) | null: | +| Pheanstalk, Beanstalk | [enqueue/pheanstalk](../transport/pheanstalk.md) | beanstalk: | +| Redis | [enqueue/redis](../transport/redis.md) | redis: | +| Amazon SQS | [enqueue/sqs](../transport/sqs.md) | sqs: | +| STOMP, RabbitMQ | [enqueue/stomp](../transport/stomp.md) | stomp: | +| WAMP | [enqueue/wamp](../transport/wamp.md) | wamp: | + +## Transport Features + +| Protocol | Priority | Delay | Expiration | Setup broker | Message bus | Heartbeat | +|:--------------:|:--------:|:--------:|:----------:|:------------:|:-----------:|:---------:| +| AMQP | No | No | Yes | Yes | Yes | No | +| RabbitMQ AMQP | Yes | Yes | Yes | Yes | Yes | Yes | +| Doctrine DBAL | Yes | Yes | No | Yes | No | No | +| Filesystem | No | No | Yes | Yes | No | No | +| Gearman | No | No | No | No | No | No | +| Google PubSub | Not impl | Not impl | Not impl | Yes | Not impl | No | +| Kafka | No | No | No | Yes | No | No | +| MongoDB | Yes | Yes | Yes | Yes | No | No | +| Pheanstalk | Yes | Yes | Yes | No | No | No | +| Redis | No | Yes | Yes | Not needed | No | No | +| Amazon SQS | No | Yes | No | Yes | Not impl | No | +| STOMP | No | No | Yes | No | Yes** | No | +| RabbitMQ STOMP | Yes | Yes | Yes | Yes*** | Yes** | Yes | +| WAMP | No | No | No | No | No | No | -* \* Possible if a RabbitMQ delay plugin is installed. * \*\* Possible if topics (exchanges) are configured on broker side manually. -* \*\*\* Possible if RabbitMQ Managment Plugin is installed. +* \*\*\* Possible if RabbitMQ Management Plugin is installed. -[back to index](../index.md) \ No newline at end of file +[back to index](../index.md) diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 000000000..5ea14d244 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,88 @@ +--- +layout: default +title: Key concepts +nav_order: 1 +--- + +# Key concepts + +If you are new to queuing system, there are some key concepts to understand to make the most of this lib. + +The library consist of several components. The components could be used independently or as integral part. + +## Components + +### Transport + +The transport is the underlying vendor-specific library that provides the queuing features: a way for programs to create, send, read messages. +Based on [queue interop](https://github.com/queue-interop/queue-interop) interfaces. Use transport directly if you need full control or access to vendor specific features. + +The most famous transports are [RabbitMQ](transport/amqp_lib.md), [Amazon SQS](transport/sqs.md), [Redis](transport/redis.md), [Filesystem](transport/filesystem.md). + +- *connection factory* creates a connection to the vendor service with vendor-specific config. +- *context* provides the Producer, the Consumer and helps create Messages. It is the most commonly used object and an implementation of [abstract factory](https://en.wikipedia.org/wiki/Abstract_factory_pattern) pattern. +- *destination* is a concept of a destination to which messages can be sent. Choose queue or topic. Destination represents broker state so expect to see same names at broker side. +- *queue* is a named destination to which messages can be sent to. Messages accumulate on queues until they are retrieved by programs (called consumers) that service those queues. +- *topic* implements [publish and subscribe](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) semantics. When you publish a message it goes to all the subscribers that are interested - so zero to many subscribers will receive a copy of the message. Some brokers do not support Pub\Sub. +- *message* describes data sent to (or received from) a destination. It has a body, headers and properties. +- *producer* sends a message to the destination. The producer implements vendor-specific logic and is in charge of converting messages between Enqueue and vendor-specific message format. +- *consumer* fetches a message from a destination. The consumer implements vendor-specific logic and is in charge of converting messages between vendor-specific message format and Enqueue. +- *subscription consumer* provides a way to consume messages from several destinations simultaneously. Some brokers do not support this feature. +- *processor* is an optional concept useful for sharing message processing logic. Vendor independent. Implements your business logic. + +Additional terms we might refer to: +- *receive and delete delivery*: the queue deletes the message when it's fetched by consumer. If processing fails, then the message is lost and won't be processed again. This is called _at most once_ processing. +- *peek and lock delivery*: the queue locks for a short amount of time a message when it's fetched by consumer, making it invisible to other consumers, in order to prevent duplicate processing and message lost. If there is no acknowledgment before the lock times out, failure is assumed and then the message is made visible again in the queue for another try. This is called _at least once_ processing. +- *an explicit acknowledgement*: the queue locks a message when it's fetched by consumer, making it invisible to other consumers, in order to prevent duplicate processing and message lost. If there is no explicit acknowledgment received before the connection is closed, failure is assumed and then the message is made visible again in the queue for another try. This is called _at least once_ processing. +- *message delivery delay*: messages are sent to the queue but won't be visible right away to consumers to fetch them. You may need it to plan an action at a specific time. +- *message expiration*: messages could be dropped of a queue within some period of time without processing. You may need it to not process stale messages. Some transports do not support the feature. +- *message priority*: message could be sent with higher priority, therefor being consumed faster. It violates first in first out concept and should be used with precautions. Some transports do not support the feature. +- *first in first out*: messages are processed in the same order than they have entered the queue. + +Lifecycle + +A queuing system is divided in two main parts: producing and consuming. +The [transport section of the Quick Start](quick_tour.md#transport) shows some code example for both parts. + +Producing part +1. The application creates a Context with a Connection factory +2. The Context helps the application to create a Message +3. The application gets a Producer from the Context +4. The application uses the Producer to send the Message to the queue + +Consuming part +1. The application gets a Consumer from the Context +2. The Consumer receives Messages from the queue +3. The Consumer uses a Processor to process a Message +4. The Processor returns a status (like `Interop\Queue\Processor::ACK`) to the Consumer +5. The Consumer requeues or removes the Message from the queue depending on the Processor returned status + +### Consumption + +The consumption component is based on top of transport. +The most important class is [QueueConsumer](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/QueueConsumer.php). +Could be used with any queue interop compatible transport. +It provides extension points which could be ad-hoc into processing flow. You can register [existing extensions](consumption/extensions.md) or write a custom one. + +### Client + +Enqueue Client is designed for as simple as possible developer experience. +It provides high-level, very opinionated API. +It manages all transport differences internally and even emulate missing features (like publish-subscribe). +Please note: Client has own logic for naming transport destinations. Expect a different transport queue\topic name from the Client topic, command name. The prefix behavior could be disabled. + +- *Topic:* Send a message to the topic when you want to notify several subscribers that something has happened. There is no way to get subscriber results. Uses the router internally to deliver messages. +- *Command:* guarantees that there is exactly one command processor\subscriber. Optionally, you can get a result. If there is no command subscriber an exception is thrown. +- *Router:* copy a message sent to the topic and duplicate it for every subscriber and send. +- *Driver* contains vendor specific logic. +- *Producer* is responsible for sending messages to the topic or command. It has nothing to do with transport's producer. +- *Message* contains data to be sent. Please note that on consumer side you have to deal with transport message. +- *Consumption:* rely on consumption component. + +## How to use Enqueue? + +There are different ways to use Enqueue: both reduce the boiler plate code you have to write to start using the Enqueue feature. +- as a [Client](client/quick_tour.md): relies on a [DSN](client/supported_brokers.md) to connect +- as a [Symfony Bundle](bundle/index.md): recommended if you are using the Symfony framework + +[back to index](index.md) diff --git a/docs/consumption/extensions.md b/docs/consumption/extensions.md index 31a47e29e..8afd61e8b 100644 --- a/docs/consumption/extensions.md +++ b/docs/consumption/extensions.md @@ -1,13 +1,20 @@ +--- +layout: default +parent: Consumption +title: Extensions +--- +{% include support.md %} + # Consumption extensions. You can learn how to register extensions in [quick tour](../quick_tour.md#consumption). There's dedicated [chapter](../bundle/consumption_extension.md) for how to add extension in Symfony app. -## [LoggerExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/LoggerExtension.php) +## [LoggerExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/LoggerExtension.php) It sets logger to queue consumer context. All log messages will go to it. -## [DoctrineClearIdentityMapExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php) +## [DoctrineClearIdentityMapExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php) It clears Doctrine's identity map after a message is processed. It reduce memory usage. @@ -15,14 +22,24 @@ It clears Doctrine's identity map after a message is processed. It reduce memory It test a database connection and if it is lost it does reconnect. Fixes "MySQL has gone away" errors. +## [DoctrineClosedEntityManagerExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue-bundle/Consumption/Extension/DoctrineClosedEntityManagerExtension.php) + +The extension interrupts consumption if an entity manager has been closed. + +## [ResetServicesExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue-bundle/Consumption/Extension/ResetServicesExtension.php) + +It resets all services with tag "kernel.reset". +For example, this includes all monolog loggers if installed and will flush/clean all buffers, +reset internal state, and get them back to a state in which they can receive log records again. + ## [ReplyExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/ReplyExtension.php) -It comes with RPC code and simplifies reply logic. +It comes with RPC code and simplifies reply logic. It takes care of sending a reply message to reply queue. ## [SetupBrokerExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Client/ConsumptionExtension/SetupBrokerExtension.php) -It responsible for configuring everything at a broker side. queues, topics, bindings and so on. +It responsible for configuring everything at a broker side. queues, topics, bindings and so on. The extension is added at runtime when `--setup-broker` option is used. ## [LimitConsumedMessagesExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/LimitConsumedMessagesExtension.php) @@ -33,8 +50,8 @@ The extension is added at runtime when `--message-limit=10` option is used. ## [LimitConsumerMemoryExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/LimitConsumerMemoryExtension.php) The extension interrupts consumption once a memory limit is reached. -The extension is added at runtime when `--memory-limit=512` option is used. -The value is Mb. +The extension is added at runtime when `--memory-limit=512` option is used. +The value is Mb. ## [LimitConsumptionTimeExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/LimitConsumptionTimeExtension.php) @@ -44,10 +61,14 @@ The extension is added at runtime when `--time-limit="now + 2 minutes"` option i ## [SignalExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Consumption/Extension/SignalExtension.php) The extension catch process signals and gracefully stops consumption. Works only on NIX platforms. - + ## [DelayRedeliveredMessageExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/pkg/enqueue/Client/ConsumptionExtension/DelayRedeliveredMessageExtension.php) -The extension checks whether the received message is redelivered (There was attempt to process message but it failed). -If so the extension reject the origin message and creates a copy message with a delay. +The extension checks whether the received message is redelivered (There was attempt to process message but it failed). +If so the extension reject the origin message and creates a copy message with a delay. + +## [ConsumerMonitoringExtension](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/monitoring.md#consumption-extension) + +There is an extension ConsumerMonitoringExtension for Enqueue QueueConsumer. It could collect consumed messages and consumer stats for you and send them to Grafana, InfluxDB or Datadog. [back to index](../index.md) diff --git a/docs/consumption/index.md b/docs/consumption/index.md new file mode 100644 index 000000000..4ecf99d9c --- /dev/null +++ b/docs/consumption/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Consumption +nav_order: 3 +has_children: true +permalink: /consumption +--- + +{:toc} diff --git a/docs/consumption/message_processor.md b/docs/consumption/message_processor.md new file mode 100644 index 000000000..07d01cdae --- /dev/null +++ b/docs/consumption/message_processor.md @@ -0,0 +1,182 @@ +--- +layout: default +parent: Consumption +title: Message processors +--- +{% include support.md %} + +# Message processor + +* [Basics](#basics) +* [Reply result](#reply-result) +* [On exceptions](#on-exceptions) +* [Examples](#examples) + + +## Basics + +The message processor is an object that actually process the message and must return a result status. +Here's example: + +```php +mailer->send('foo@example.com', $message->getBody()); + + return self::ACK; + } +} +``` + +By returning `self::ACK` a processor tells a broker that the message has been processed correctly. + +There are other statuses: + +* `self::ACK` - Use this constant when the message is processed successfully and the message could be removed from the queue. +* `self::REJECT` - Use this constant when the message is not valid or could not be processed. The message is removed from the queue. +* `self::REQUEUE` - Use this constant when the message is not valid or could not be processed right now but we can try again later + +Look at the next example that shows the message validation before sending a mail. If the message is not valid a processor rejects it. + +```php +getBody()); + if ($user = $this->userRepository->find($data['userId'])) { + return self::REJECT; + } + + $this->mailer->send($user->getEmail(), $data['text']); + + return self::ACK; + } +} +``` + +It is possible to find out whether the message failed previously or not. +There is `isRedelivered` method for that. +If it returns true than there was attempt to process message. + +```php +isRedelivered()) { + return self::REQUEUE; + } + + $this->mailer->send('foo@example.com', $message->getBody()); + + return self::ACK; + } +} +``` + +The second argument is your context. You can use it to send messages to other queues\topics. + +```php +mailer->send('foo@example.com', $message->getBody()); + + $queue = $context->createQueue('anotherQueue'); + $message = $context->createMessage('Message has been sent'); + $context->createProducer()->send($queue, $message); + + return self::ACK; + } +} +``` + +## Reply result + +The consumption component provide some useful extensions, for example there is an extension that makes RPC processing simpler. +The producer might wait for a reply from a consumer and in order to send it a processor has to return a reply result. +Don't forget to add `ReplyExtension`. + +```php +mailer->send('foo@example.com', $message->getBody()); + + $replyMessage = $context->createMessage('Message has been sent'); + + return Result::reply($replyMessage); + } +} + +/** @var \Interop\Queue\Context $context */ + +$queueConsumer = new QueueConsumer($context, new ChainExtension([ + new ReplyExtension() +])); + +$queueConsumer->bind('foo', new SendMailProcessor()); + +$queueConsumer->consume(); +``` + + +## On exceptions + +It is advised to not catch exceptions and [fail fast](https://en.wikipedia.org/wiki/Fail-fast). +Also consider using [supervisord](supervisord.org) or similar process manager to restart exited consumers. + +Despite advising to fail there are some cases where you might want to catch exceptions. + +* A message validator throws an exception on invalid message. It is better to catch it and return `REJECT`. +* Some transports ([Doctrine DBAL](../transport/dbal.md), [Filesystem](../transport/filesystem.md), [Redis](../transport/redis.md)) does notice an error, +and therefor won't be able to redeliver the message. The message is completely lost. You might want to catch an exception to properly redelivery\requeue the message. + +# Examples + +Feel free to contribute your own. + +* [LiipImagineBundle. ResolveCacheProcessor](https://github.com/liip/LiipImagineBundle/blob/713e36f5df353d7c5345daed5a2eefc23c103849/Async/ResolveCacheProcessor.php#L1) +* [EnqueueElasticaBundle. ElasticaPopulateProcessor](https://github.com/php-enqueue/enqueue-elastica-bundle/blob/7c05c55b1667f9cae98325257ba24fc101f87f97/Async/ElasticaPopulateProcessor.php#L1) +* [formapro/pvm. HandleAsyncTransitionProcessor](https://github.com/formapro/pvm/blob/d5e989a77eb1540a93e69abacc446b3d7937292d/src/Enqueue/HandleAsyncTransitionProcessor.php#L1) +* [php-quartz. EnqueueRemoteTransportProcessor](https://github.com/php-quartz/quartz-dev/blob/91690aa535b0322510b4555dab59d6ae9d7044e5/pkg/bridge/Enqueue/EnqueueRemoteTransportProcessor.php#L1) +* [php-comrade. CreateJobProcessor](https://github.com/php-comrade/comrade-dev/blob/43c0662b74340aae318bceb15d8564670325dcee/apps/jm/src/Queue/CreateJobProcessor.php#L1) +* [prooph/psb-enqueue-producer. EnqueueMessageProcessor](https://github.com/prooph/psb-enqueue-producer/blob/c80914a4092b42b2d0a7ba698b216e0af23bab42/src/EnqueueMessageProcessor.php#L1) + + +[back to index](../index.md) diff --git a/docs/contribution.md b/docs/contribution.md index dce0a0f07..68d051fc5 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -1,8 +1,16 @@ +--- +layout: default +title: Contribution +nav_order: 99 +--- + +{% include support.md %} + # Contribution -To contribute you have to fork a [enqueue-dev](https://github.com/php-enqueue/enqueue-dev) repository. -Clone it locally. - +To contribute you have to send a pull request to [enqueue-dev](https://github.com/php-enqueue/enqueue-dev) repository. +The pull requests to read only subtree split [repositories](https://github.com/php-enqueue/enqueue-dev/blob/master/bin/subtree-split#L46) will be closed. + ## Setup environment ``` @@ -13,17 +21,34 @@ composer install Once you did it you can work on a feature or bug fix. +If you need, you can also use composer scripts to run code linting and static analysis: +* For code style linting, run `composer run cs-lint`. Optionally add file names: +`composer run cs-lint pkg/null/NullTopic.php` for example. +* You can also fix your code style with `composer run cs-fix`. +* Static code analysis can be run using `composer run phpstan`. As above, you can pass specific files. + ## Testing -To run tests simply run +To run tests ``` -./bin/dev -t +./bin/test.sh ``` -## Commit +or for a package only: + + +``` +./bin/test.sh pkg/enqueue +``` + +## Commit + +When you try to commit changes `php-cs-fixer` is run. It fixes all coding style issues. Don't forget to stage them and commit everything. +Once everything is done open a pull request on official repository. + +## WTF?! -When you try to commit changes `php-cs-fixer` is run. It fixes all coding style issues. Dont forget to stage them and commit everything. -Once everything is done open a pull request on official repository. +* If you get `rabbitmqssl: forward host lookup failed: Unknown host, wait for service rabbitmqssl:5671` do `docker compose down`. -[back to index](index.md) \ No newline at end of file +[back to index](index.md) diff --git a/docs/cookbook/symfony/how-to-change-consume-command-logger.md b/docs/cookbook/symfony/how-to-change-consume-command-logger.md new file mode 100644 index 000000000..c2c281753 --- /dev/null +++ b/docs/cookbook/symfony/how-to-change-consume-command-logger.md @@ -0,0 +1,34 @@ +--- +layout: default +nav_exclude: true +--- +{% include support.md %} + +# How to change consume command logger + +By default `bin/console enqueue:consume` (or `bin/console enqueue:transport:consume`) command prints messages to output. +The amount of info could be controlled by verbosity option (-v, -vv, -vvv). + +In order to change the default logger used by a command you have to register a `LoggerExtension` just before the default one. +The extension asks you for a logger service, so just pass the one you want to use. +Here's how you can do it. + +```yaml +// config/services.yaml + +services: + app_logger_extension: + class: 'Enqueue\Consumption\Extension\LoggerExtension' + public: false + arguments: ['@logger'] + tags: + - { name: 'enqueue.consumption.extension', priority: 255 } + +``` + +The logger extension with the highest priority will set its logger. + +[back to index](../../index.md) + + + diff --git a/docs/dsn.md b/docs/dsn.md new file mode 100644 index 000000000..bd6cf2c0c --- /dev/null +++ b/docs/dsn.md @@ -0,0 +1,123 @@ +--- +layout: default +title: DSN Parser +nav_order: 92 +--- +{% include support.md %} + +## DSN Parser. + +The [enqueue/dsn](https://github.com/php-enqueue/dsn) tool helps to parse DSN\URI string. +The tool is used by Enqueue transports to parse DSNs. + +## Installation + +```bash +composer req enqueue/dsn 0.9.x +``` + +### Examples + +Basic usage: + +```php +getSchemeProtocol(); // 'mysql' +$dsn->getScheme(); // 'mysql+pdo' +$dsn->getSchemeExtensions(); // ['pdo'] +$dsn->getUser(); // 'user' +$dsn->getPassword(); // 'password' +$dsn->getHost(); // 'localhost' +$dsn->getPort(); // 3306 + +$dsn->getQueryString(); // 'connection_timeout=123' +$dsn->getQuery(); // ['connection_timeout' => '123'] +$dsn->getString('connection_timeout'); // '123' +$dsn->getDecimal('connection_timeout'); // 123 +``` + +Parse Cluster DSN: + +```php +getUser(); // 'user' +$dsns[0]->getPassword(); // 'password' +$dsns[0]->getHost(); // 'foo' +$dsns[0]->getPort(); // 3306 + +$dsns[1]->getUser(); // 'user' +$dsns[1]->getPassword(); // 'password' +$dsns[1]->getHost(); // 'bar' +$dsns[1]->getPort(); // 5678 +``` + +Some parts could be omitted: + +```php +getSchemeProtocol(); // 'sqs' +$dsn->getScheme(); // 'sqs' +$dsn->getSchemeExtensions(); // [] +$dsn->getUser(); // null +$dsn->getPassword(); // null +$dsn->getHost(); // null +$dsn->getPort(); // null + +$dsn->getString('key'); // 'aKey' +$dsn->getString('secret'); // 'aSecret' +``` + +Get typed query params: + +```php +getDecimal('decimal'); // 12 +$dsn->getOctal('decimal'); // 0666 +$dsn->getFloat('float'); // 1.2 +$dsn->getBool('bool'); // true +$dsn->getArray('array')->getString(0); // val +$dsn->getArray('array')->getDecimal(1); // 123 +$dsn->getArray('array')->toArray(); // [val] +``` + +Throws exception if DSN not valid: + +```php +getDecimal('connection_timeout'); // throws exception here +``` + +[back to index](index.md) diff --git a/docs/elastica-bundle/overview.md b/docs/elastica-bundle/overview.md new file mode 100644 index 000000000..22702a813 --- /dev/null +++ b/docs/elastica-bundle/overview.md @@ -0,0 +1,15 @@ +--- +layout: default +title: Elastica bundle +nav_order: 4 +--- +{% include support.md %} + +# Enqueue Elastica Bundle + +`EnqueueElasticaBundle` provides extra features for `FOSElasticaBundle` such as: + +* [Speed up populate command.](https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/master/doc/cookbook/speed-up-populate-command.md) +* [Doctrine queue listener.](https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/master/doc/cookbook/doctrine-queue-listener.md) + +[back to index](../index.md) diff --git a/docs/images/datadog_monitoring.png b/docs/images/datadog_monitoring.png new file mode 100644 index 000000000..731aff3b3 Binary files /dev/null and b/docs/images/datadog_monitoring.png differ diff --git a/docs/images/grafana_monitoring.jpg b/docs/images/grafana_monitoring.jpg new file mode 100644 index 000000000..5d845e351 Binary files /dev/null and b/docs/images/grafana_monitoring.jpg differ diff --git a/docs/images/magento2_enqueue_configuration.png b/docs/images/magento2_enqueue_configuration.png new file mode 100644 index 000000000..475b461d5 Binary files /dev/null and b/docs/images/magento2_enqueue_configuration.png differ diff --git a/docs/images/magento_enqueue_configuration.jpeg b/docs/images/magento_enqueue_configuration.jpeg new file mode 100644 index 000000000..73b39731e Binary files /dev/null and b/docs/images/magento_enqueue_configuration.jpeg differ diff --git a/docs/index.md b/docs/index.md index 6dfcaebc9..d38cb873a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,28 +1,105 @@ -# Documentation. +--- +# Feel free to add content and custom Front Matter to this file. +# To modify the layout, see https://jekyllrb.com/docs/themes/#overriding-theme-defaults + +layout: default +title: Index +nav_order: 0 +--- + +{% include support.md %} + +## Documentation. * [Quick tour](quick_tour.md) -* Transports - - [AMQP](amqp_transport.md) - - [STOMP](stomp_transport.md) - - [NULL](null_transport.md) -* Consumption +* [Key concepts](concepts.md) +* [Transports](#transports) + - Amqp based on [the ext](transport/amqp.md), [bunny](transport/amqp_bunny.md), [the lib](transport/amqp_lib.md) + - [Amazon SNS-SQS](transport/snsqs.md) + - [Amazon SQS](transport/sqs.md) + - [Google PubSub](transport/gps.md) + - [Beanstalk (Pheanstalk)](transport/pheanstalk.md) + - [Gearman](transport/gearman.md) + - [Kafka](transport/kafka.md) + - [Stomp](transport/stomp.md) + - [Redis](transport/redis.md) + - [Wamp](transport/wamp.md) + - [Doctrine DBAL](transport/dbal.md) + - [Filesystem](transport/filesystem.md) + - [Null](transport/null.md) +* [Consumption](#consumption) - [Extensions](consumption/extensions.md) -* Client + - [Message processor](consumption/message_processor.md) +* [Client](#client) + - [Quick tour](client/quick_tour.md) - [Message examples](client/message_examples.md) - [Supported brokers](client/supported_brokers.md) - [Message bus](client/message_bus.md) -* Job queue + - [RPC call](client/rpc_call.md) + - [Extensions](client/extensions.md) +* [Job queue](#job-queue) - [Run unique job](job_queue/run_unique_job.md) - [Run sub job(s)](job_queue/run_sub_job.md) -* EnqueueBundle (Symfony). +* [EnqueueBundle (Symfony)](bundle/index.md) - [Quick tour](bundle/quick_tour.md) - [Config reference](bundle/config_reference.md) - [Cli commands](bundle/cli_commands.md) + - [Message producer](bundle/message_producer.md) + - [Message processor](bundle/message_processor.md) + - [Async events](bundle/async_events.md) + - [Async commands](bundle/async_commands.md) - [Job queue](bundle/job_queue.md) - [Consumption extension](bundle/consumption_extension.md) - [Production settings](bundle/production_settings.md) - - [Debuging](bundle/debuging.md) + - [Debugging](bundle/debugging.md) - [Functional testing](bundle/functional_testing.md) -* Development +* [Laravel](#laravel) + - [Quick tour](laravel/quick_tour.md) + - [Queues](laravel/queues.md) +* [Magento](#magento) + - [Quick tour](magento/quick_tour.md) + - [Cli commands](magento/cli_commands.md) +* [Magento2](#magento2) + - [Quick tour](magento2/quick_tour.md) + - [Cli commands](magento2/cli_commands.md) +* [Yii](#yii) + - [AMQP Interop driver](yii/amqp_driver.md) +* [EnqueueElasticaBundle. Overview](elastica-bundle/overview.md) +* [DSN Parser](dsn.md) +* [Monitoring](monitoring.md) +* [Use cases](#use-cases) + - [Symfony. Async event dispatcher](async_event_dispatcher/quick_tour.md) + - [Monolog. Send messages to message queue](monolog/send-messages-to-mq.md) +* [Development](#development) - [Contribution](contribution.md) - \ No newline at end of file + +## Cookbook + +* [Symfony](#symfony-cookbook) + - [How to change consume command logger](cookbook/symfony/how-to-change-consume-command-logger.md) + +## Blogs + +* [Getting Started with RabbitMQ in PHP](https://blog.forma-pro.com/getting-started-with-rabbitmq-in-php-84d331e20a66) +* [Getting Started with RabbitMQ in Symfony](https://blog.forma-pro.com/getting-started-with-rabbitmq-in-symfony-cb06e0b674f1) +* [The how and why of the migration from RabbitMqBundle to EnqueueBundle](https://blog.forma-pro.com/the-how-and-why-of-the-migration-from-rabbitmqbundle-to-enqueuebundle-6c4054135e2b) +* [RabbitMQ redelivery pitfalls](https://blog.forma-pro.com/rabbitmq-redelivery-pitfalls-440e0347f4e0) +* [RabbitMQ delayed messaging](https://blog.forma-pro.com/rabbitmq-delayed-messaging-da802e3a0aa9) +* [RabbitMQ tutorials based on AMQP interop](https://blog.forma-pro.com/rabbitmq-tutorials-based-on-amqp-interop-cf325d3b4912) +* [LiipImagineBundle. Process images in background](https://blog.forma-pro.com/liipimaginebundle-process-images-in-background-3838c0ed5234) +* [FOSElasticaBundle. Improve performance of fos:elastica:populate command](https://github.com/php-enqueue/enqueue-elastica-bundle) +* [Message bus to every PHP application](https://blog.forma-pro.com/message-bus-to-every-php-application-42a7d3fbb30b) +* [Symfony Async EventDispatcher](https://blog.forma-pro.com/symfony-async-eventdispatcher-d01055a255cf) +* [Spool Swiftmailer emails to real message queue.](https://blog.forma-pro.com/spool-swiftmailer-emails-to-real-message-queue-9ecb8b53b5de) +* [Yii PHP Framework has adopted AMQP Interop.](https://blog.forma-pro.com/yii-php-framework-has-adopted-amqp-interop-85ab47c9869f) +* [(En)queue Symfony console commands](http://tech.yappa.be/enqueue-symfony-console-commands) +* [From RabbitMq to PhpEnqueue via Symfony Messenger](https://medium.com/@stefanoalletti_40357/from-rabbitmq-to-phpenqueue-via-symfony-messenger-b8260d0e506c) + +## Contributing to this documentation + +To run this documentation locally, you can either create Jekyll environment on your local computer or use docker container. +To run docker container you can use a command from repository root directory: +```shell +docker run -p 4000:4000 --rm --volume="${PWD}/docs:/srv/jekyll" -it jekyll/jekyll jekyll serve --watch +``` +Documentation will then be available for you on http://localhost:4000/ once build completes and rebuild automatically on changes. diff --git a/docs/job_queue/index.md b/docs/job_queue/index.md new file mode 100644 index 000000000..f29a8a97a --- /dev/null +++ b/docs/job_queue/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Job Queue +nav_order: 5 +has_children: true +permalink: /job-queue +--- + +{:toc} diff --git a/docs/job_queue/run_sub_job.md b/docs/job_queue/run_sub_job.md index e0322922e..98f2938b3 100644 --- a/docs/job_queue/run_sub_job.md +++ b/docs/job_queue/run_sub_job.md @@ -1,15 +1,23 @@ +--- +layout: default +parent: Job Queue +title: Run sub job +nav_order: 2 +--- +{% include support.md %} + ## Job queue. Run sub job -It shows how you can create and run a sub job, which it is executed separately. -You can create as many sub jobs as you like. -They will be executed in parallel. +It shows how you can create and run a sub job, which it is executed separately. +You can create as many sub jobs as you like. +They will be executed in parallel. ```php jobRunner->runUnique($message->getMessageId(), 'aJobName', function (JobRunner $runner) { $runner->createDelayed('aSubJobName1', function (JobRunner $runner, Job $childJob) { - $this->producer->send('aJobTopic', [ + $this->producer->sendEvent('aJobTopic', [ 'jobId' => $childJob->getId(), // other data required by sub job ]); @@ -54,9 +62,9 @@ class SubJobProcessor implements Processor return true; }); - return $result ? Result::ACK : Result::REJECT; + return $result ? self::ACK : self::REJECT; } } ``` -[back to index](../index.md) \ No newline at end of file +[back to index](../index.md) diff --git a/docs/job_queue/run_unique_job.md b/docs/job_queue/run_unique_job.md index cfb145d2e..c6869ece4 100644 --- a/docs/job_queue/run_unique_job.md +++ b/docs/job_queue/run_unique_job.md @@ -1,3 +1,11 @@ +--- +layout: default +parent: Job Queue +title: Run unique job +nav_order: 1 +--- +{% include support.md %} + ## Job queue. Run unique job There is job queue component build on top of a transport. It provides some additional features: @@ -6,18 +14,18 @@ There is job queue component build on top of a transport. It provides some addit * Run unique job feature. If used guarantee that there is not any job with the same name running same time. * Sub jobs. If used allow split a big job into smaller pieces and process them asynchronously and in parallel. * Depended job. If used allow send a message when the whole job is finished (including sub jobs). - + Here's some examples. -It shows how you can run unique job using job queue (The configuration is described in a dedicated chapter). +It shows how you can run unique job using job queue (The configuration is described in a dedicated chapter). ```php - 'interop', + 'connections' => [ + 'interop' => [ + 'driver' => 'interop', + 'dsn' => 'amqp+rabbitmq://guest:guest@localhost:5672/%2f', + ], + ], +]; +``` + +Here's a [full list](../transport) of supported transports. + +## Usage + +Same as standard [Laravel Queues](https://laravel.com/docs/5.4/queues) + +Send message example: + +```php +onConnection('interop'); + +dispatch($job); +``` + +Consume messages: + +```bash +$ php artisan queue:work interop +``` + +## Amqp interop + +```php + env('QUEUE_DRIVER', 'interop'), + + 'connections' => [ + 'interop' => [ + 'driver' => 'interop', + + // connects to localhost + 'dsn' => 'amqp:', // + + // could be "rabbitmq_dlx", "rabbitmq_delay_plugin", instance of DelayStrategy interface or null + // 'delay_strategy' => 'rabbitmq_dlx' + ], + ], +]; +``` + +[back to index](../index.md) diff --git a/docs/laravel/quick_tour.md b/docs/laravel/quick_tour.md new file mode 100644 index 000000000..c57ad9e7d --- /dev/null +++ b/docs/laravel/quick_tour.md @@ -0,0 +1,112 @@ +--- +layout: default +parent: Laravel +title: Quick tour +nav_order: 1 +--- +{% include support.md %} + +# Laravel Queue. Quick tour. + +The [enqueue/laravel-queue](https://github.com/php-enqueue/laravel-queue) is message queue bridge for Enqueue. You can use all transports built on top of [queue-interop](https://github.com/queue-interop/queue-interop) including [all supported](https://github.com/php-enqueue/enqueue-dev/tree/master/docs/transport) by Enqueue. + +The package allows you to use queue interop transport the [laravel way](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/laravel/queues.md) as well as integrates the [enqueue simple client](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/laravel/quick_tour.md#enqueue-simple-client). + +**NOTE:** The part of this code was originally proposed as a PR to [laravel/framework#20148](https://github.com/laravel/framework/pull/20148). It was closed without much explanations, so I decided to open source it as a stand alone package. + +## Install + +You have to install `enqueue/laravel-queue` packages and one of the [supported transports](https://github.com/php-enqueue/enqueue-dev/tree/master/docs/transport). + +```bash +$ composer require enqueue/laravel-queue enqueue/fs +``` + +## Register service provider + +```php + [ + Enqueue\LaravelQueue\EnqueueServiceProvider::class, + ], +]; +``` + +## Laravel queues + +At this stage you are already able to use [laravel queues](queues.md). + +## Enqueue Simple client + +If you want to use [enqueue/simple-client](https://github.com/php-enqueue/simple-client) in your Laravel application you have perform additional steps . +You have to install the client library, in addition to what you've already installed: + +```bash +$ composer require enqueue/simple-client +``` + +Create `config/enqueue.php` file and put a client configuration there: +Here's an example of what it might look like: + +```php + [ + 'transport' => [ + 'default' => 'file://'.realpath(__DIR__.'/../storage/enqueue') + ], + 'client' => [ + 'router_topic' => 'default', + 'router_queue' => 'default', + 'default_queue' => 'default', + ], + ], +]; +``` + +Register processor: + +```php +resolving(SimpleClient::class, function (SimpleClient $client, $app) { + $client->bindTopic('enqueue_test', function(Message $message) { + // do stuff here + + return Processor::ACK; + }); + + return $client; +}); + +``` + +Send message: + +```php +sendEvent('enqueue_test', 'The message'); +``` + +Consume messages: + +```bash +$ php artisan enqueue:consume -vvv --setup-broker +``` + +[back to index](../index.md) diff --git a/docs/magento/cli_commands.md b/docs/magento/cli_commands.md new file mode 100644 index 000000000..4829b31b5 --- /dev/null +++ b/docs/magento/cli_commands.md @@ -0,0 +1,153 @@ +--- +layout: default +parent: Magento +title: CLI commands +nav_order: 2 +--- +{% include support.md %} + +# Magento. Cli commands + +The enqueue Magento extension provides several commands. +The most useful one `enqueue:consume` connects to the broker and process the messages. +Other commands could be useful during debugging (like `enqueue:topics`) or deployment (like `enqueue:setup-broker`). + +* [enqueue:consume](#enqueueconsume) +* [enqueue:produce](#enqueueproduce) +* [enqueue:setup-broker](#enqueuesetup-broker) +* [enqueue:queues](#enqueuequeues) +* [enqueue:topics](#enqueuetopics) + +## enqueue:consume + +``` +php shell/enqueue.php enqueue:consume --help +Usage: + enqueue:consume [options] [--] []... + enq:c + +Arguments: + client-queue-names Queues to consume messages from + +Options: + --message-limit=MESSAGE-LIMIT Consume n messages and exit + --time-limit=TIME-LIMIT Consume messages during this time + --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB + --setup-broker Creates queues, topics, exchanges, binding etc on broker side. + --idle-timeout=IDLE-TIMEOUT The time in milliseconds queue consumer idle if no message has been received. + --receive-timeout=RECEIVE-TIMEOUT The time in milliseconds queue consumer waits for a message. + --skip[=SKIP] Queues to skip consumption of messages from (multiple values allowed) + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "test"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A client's worker that processes messages. By default it connects to default queue. It select an appropriate message processor based on a message headers +``` + +## enqueue:produce + +``` +php shell/enqueue.php enqueue:produce --help +Usage: + enqueue:produce + enq:p + +Arguments: + topic A topic to send message to + message A message to send + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command to send a message to topic +``` + +## enqueue:setup-broker + +``` +php shell/enqueue.php enqueue:setup-broker --help +Usage: + enqueue:setup-broker + enq:sb + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + Creates all required queues +``` + +## enqueue:queues + +``` +/bin/console enqueue:queues --help +Usage: + enqueue:queues + enq:m:q + debug:enqueue:queues + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command shows all available queues and some information about them. +``` + +## enqueue:topics + +``` +php shell/enqueue.php enqueue:topics --help +Usage: + enqueue:topics + enq:m:t + debug:enqueue:topics + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command shows all available topics and some information about them. +``` + +[back to index](../index.md) + diff --git a/docs/magento/index.md b/docs/magento/index.md new file mode 100644 index 000000000..291ddf549 --- /dev/null +++ b/docs/magento/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Magento +has_children: true +nav_order: 7 +permalink: /magento +--- + +{:toc} diff --git a/docs/magento/quick_tour.md b/docs/magento/quick_tour.md new file mode 100644 index 000000000..95a013a43 --- /dev/null +++ b/docs/magento/quick_tour.md @@ -0,0 +1,99 @@ +--- +layout: default +parent: Magento +title: Quick tour +nav_order: 1 +--- +{% include support.md %} + +# Magento Enqueue. Quick tour + +The module integrates [Enqueue Client](../client/quick_tour.md) with Magento1. You can send and consume messages to different message queues such as RabbitMQ, AMQP, STOMP, Amazon SQS, Kafka, Redis, Google PubSub, Gearman, Beanstalk, Google PubSub and others. Or integrate Magento2 app with other applications or service via [Message Bus](../client/message_bus.md). +There is [a module](../magento2/quick_tour.md) for Magento2 too. + +## Installation + +We use [composer](https://getcomposer.org/) and [cotya/magento-composer-installer](https://github.com/Cotya/magento-composer-installer) plugin to install [magento-enqueue](https://github.com/php-enqueue/magento-enqueue) extension. + +To install libraries run the commands in the application root directory. + +```bash +composer require "magento-hackathon/magento-composer-installer:~3.0" +composer require "enqueue/magento-enqueue:*@dev" "enqueue/amqp-ext" +``` + +_**Note**: You could use not only AMQP transport but any other [available](../transport)._ + +## Configuration + +At this stage we have configure the Enqueue extension in Magento backend. +The config is here: `System -> Configuration -> Enqueue Message Queue`. +Here's the example of Amqp transport that connects to RabbitMQ broker on localhost: + + +![Сonfiguration](../images/magento_enqueue_configuration.jpeg) + +## Publish Message + +To send a message you have to take enqueue helper and call `send` method. + +```php +send('a_topic', 'aMessage'); +``` + +## Message Consumption + +I assume you have `acme` Magento module properly created, configured and registered. +To consume messages you have to define a processor class first: + +```php +getBody() -> 'payload' + + return self::ACK; // acknowledge message + // return self::REJECT; // reject message + // return self::REQUEUE; // requeue message + } +} +``` + +than subscribe it to a topic or several topics: + + +```xml + + + + + + + + a_topic + acme/async_foo + + + + + +``` + +and run message consume command: + +```bash +$ php shell/enqueue.php enqueue:consume -vvv --setup-broker +``` + +[back to index](../index.md) diff --git a/docs/magento2/cli_commands.md b/docs/magento2/cli_commands.md new file mode 100644 index 000000000..3f72093b9 --- /dev/null +++ b/docs/magento2/cli_commands.md @@ -0,0 +1,153 @@ +--- +layout: default +parent: Magento 2 +title: CLI commands +nav_order: 2 +--- +{% include support.md %} + +# Magento2. Cli commands + +The enqueue Magento extension provides several commands. +The most useful one `enqueue:consume` connects to the broker and process the messages. +Other commands could be useful during debugging (like `enqueue:topics`) or deployment (like `enqueue:setup-broker`). + +* [enqueue:consume](#enqueueconsume) +* [enqueue:produce](#enqueueproduce) +* [enqueue:setup-broker](#enqueuesetup-broker) +* [enqueue:queues](#enqueuequeues) +* [enqueue:topics](#enqueuetopics) + +## enqueue:consume + +``` +php bin/magento enqueue:consume --help +Usage: + enqueue:consume [options] [--] []... + enq:c + +Arguments: + client-queue-names Queues to consume messages from + +Options: + --message-limit=MESSAGE-LIMIT Consume n messages and exit + --time-limit=TIME-LIMIT Consume messages during this time + --memory-limit=MEMORY-LIMIT Consume messages until process reaches this memory limit in MB + --setup-broker Creates queues, topics, exchanges, binding etc on broker side. + --idle-timeout=IDLE-TIMEOUT The time in milliseconds queue consumer idle if no message has been received. + --receive-timeout=RECEIVE-TIMEOUT The time in milliseconds queue consumer waits for a message. + --skip[=SKIP] Queues to skip consumption of messages from (multiple values allowed) + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "test"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A client's worker that processes messages. By default it connects to default queue. It select an appropriate message processor based on a message headers +``` + +## enqueue:produce + +``` +php bin/magento enqueue:produce --help +Usage: + enqueue:produce + enq:p + +Arguments: + topic A topic to send message to + message A message to send + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command to send a message to topic +``` + +## enqueue:setup-broker + +``` +php bin/magento enqueue:setup-broker --help +Usage: + enqueue:setup-broker + enq:sb + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + Creates all required queues +``` + +## enqueue:queues + +``` +php bin/magento enqueue:queues --help +Usage: + enqueue:queues + enq:m:q + debug:enqueue:queues + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command shows all available queues and some information about them. +``` + +## enqueue:topics + +``` +php bin/magento enqueue:topics --help +Usage: + enqueue:topics + enq:m:t + debug:enqueue:topics + +Options: + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The environment name [default: "dev"] + --no-debug Switches off debug mode + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + A command shows all available topics and some information about them. +``` + +[back to index](../index.md#magento2) + diff --git a/docs/magento2/index.md b/docs/magento2/index.md new file mode 100644 index 000000000..9ae85803a --- /dev/null +++ b/docs/magento2/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Magento 2 +has_children: true +nav_order: 8 +permalink: /magento2 +--- + +{:toc} diff --git a/docs/magento2/quick_tour.md b/docs/magento2/quick_tour.md new file mode 100644 index 000000000..5063ab56c --- /dev/null +++ b/docs/magento2/quick_tour.md @@ -0,0 +1,108 @@ +--- +layout: default +parent: Magento 2 +title: Quick tour +nav_order: 1 +--- +{% include support.md %} + +# Magento2 EnqueueModule + +The module integrates [Enqueue Client](../client/quick_tour.md) with Magento2. You can send and consume messages to different message queues such as RabbitMQ, AMQP, STOMP, Amazon SQS, Kafka, Redis, Google PubSub, Gearman, Beanstalk, Google PubSub and others. Or integrate Magento2 app with other applications or service via [Message Bus](../client/message_bus.md). +There is [a module](../magento/quick_tour.md) for Magento1 too. + +## Installation + +We recommend using [composer](https://getcomposer.org/) to install [magento2-enqueue](https://github.com/php-enqueue/magento-enqueue) module. To install libraries run the commands in the application root directory. + +```bash +composer require "enqueue/magento2-enqueue:*@dev" "enqueue/amqp-ext" +``` + +Run setup:upgrade so Magento2 picks up the installed module. + +```bash +php bin/magento setup:upgrade +``` + +## Configuration + +At this stage we have configure the Enqueue extension in Magento backend. +The config is here: `Stores -> Configuration -> General -> Enqueue Message Queue`. +Here's the example of Amqp transport that connects to RabbitMQ broker on localhost: + +![Сonfiguration](../images/magento2_enqueue_configuration.png) + +## Publish Message + +To send a message you have to take enqueue helper and call `send` method. + +```php +create('Enqueue\Magento2\Model\EnqueueManager'); +$enqueueManager->sendEvent('a_topic', 'aMessage'); + +// or a command with a possible reply +$reply = $enqueueManager->sendCommand('a_topic', 'aMessage', true); + +$replyMessage = $reply->receive(5000); // wait for 5 sec +``` + +## Message Consumption + +I assume you have `acme` Magento module properly created, configured and registered. +To consume messages you have to define a processor class first: + +```php +getBody() -> 'payload' + + return self::ACK; // acknowledge message + // return self::REJECT; // reject message + // return self::REQUEUE; // requeue message + } +} +``` + +than subscribe it to a topic or several topics: + + +```xml + + + + + + + + a_topic + Acme\Module\Helper\Async\foo + + + + + +``` + +and run message consume command: + +```bash +$ php bin/magento enqueue:consume -vvv --setup-broker +``` + +[back to index](../index.md#magento2) diff --git a/docs/messages.md b/docs/messages.md new file mode 100644 index 000000000..362991ba8 --- /dev/null +++ b/docs/messages.md @@ -0,0 +1,22 @@ +--- +layout: default +title: Messages +nav_order: 90 +--- +{% include support.md %} + +## Pull request to readonly repo. + +Thanks for your pull request! We love contributions. + +However, this repository is what we call a "subtree split": a read-only copy of one directory of the main enqueue-dev repository. It is used by Composer to allow developers to depend on specific Enqueue package. + +If you want to contribute, you should instead open a pull request on the main repository: + +https://github.com/php-enqueue/enqueue-dev + +Read the contribution guide + +https://github.com/php-enqueue/enqueue-dev/blob/master/docs/contribution.md + +Thank you for your contribution! diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 000000000..0643b425e --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,342 @@ +--- +layout: default +title: Monitoring +nav_order: 95 +--- + +{% include support.md %} + +# Monitoring. + +Enqueue provides a tool for monitoring message queues. +With it, you can control how many messages were sent, how many processed successfully or failed. +How many consumers are working, their up time, processed messages stats, memory usage and system load. +The tool could be integrated with virtually any analytics and monitoring platform. +There are several integration: + * [Datadog StatsD](https://datadoghq.com) + * [InfluxDB](https://www.influxdata.com/) and [Grafana](https://grafana.com/) + * [WAMP (Web Application Messaging Protocol)](https://wamp-proto.org/) +We are working on a JS\WAMP based real-time UI tool, for more information please [contact us](opensource@forma-pro.com). + +![Grafana Monitoring](images/grafana_monitoring.jpg) + +[contact us](mailto:opensource@forma-pro.com) if need a Grafana template such as on the picture. + +* [Installation](#installation) +* [Track sent messages](#track-sent-messages) +* [Track consumed message](#track-consumed-message) +* [Track consumer metrics](#track-consumer-metrics) +* [Consumption extension](#consumption-extension) +* [Enqueue Client Extension](#enqueue-client-extension) +* [InfluxDB Storage](#influxdb-storage) +* [Datadog Storage](#datadog-storage) +* [WAMP (Web Socket Messaging Protocol) Storage](#wamp-(web-socket-messaging-protocol)-storage) +* [Symfony App](#symfony-app) + +## Installation + +```bash +composer req enqueue/monitoring:0.9.x-dev +``` + +## Track sent messages + +```php +create('influxdb://127.0.0.1:8086?db=foo'); +$statsStorage->pushSentMessageStats(new SentMessageStats( + (int) (microtime(true) * 1000), // timestamp + 'queue_name', // queue + 'aMessageId', + 'aCorrelationId', + [], // headers + [] // properties +)); +``` + +or, if you work with [Queue Interop](https://github.com/queue-interop/queue-interop) transport here's how you can track a message sent + +```php +createQueue('foo'); +$message = $context->createMessage('body'); + +$context->createProducer()->send($queue, $message); + +$statsStorage = (new GenericStatsStorageFactory())->create('influxdb://127.0.0.1:8086?db=foo'); +$statsStorage->pushSentMessageStats(new SentMessageStats( + (int) (microtime(true) * 1000), + $queue->getQueueName(), + $message->getMessageId(), + $message->getCorrelationId(), + $message->getHeaders()[], + $message->getProperties() +)); +``` + +## Track consumed message + +```php +create('influxdb://127.0.0.1:8086?db=foo'); +$statsStorage->pushConsumedMessageStats(new ConsumedMessageStats( + 'consumerId', + (int) (microtime(true) * 1000), // now + $receivedAt, + 'aQueue', + 'aMessageId', + 'aCorrelationId', + [], // headers + [], // properties + false, // redelivered or not + ConsumedMessageStats::STATUS_ACK +)); +``` + +or, if you work with [Queue Interop](https://github.com/queue-interop/queue-interop) transport here's how you can track a message sent + +```php +createQueue('foo'); + +$consumer = $context->createConsumer($queue); + +$consumerId = uniqid('consumer-id', true); // we suggest using UUID here +if ($message = $consumer->receiveNoWait()) { + $receivedAt = (int) (microtime(true) * 1000); + + // heavy processing here. + + $consumer->acknowledge($message); + + $statsStorage = (new GenericStatsStorageFactory())->create('influxdb://127.0.0.1:8086?db=foo'); + $statsStorage->pushConsumedMessageStats(new ConsumedMessageStats( + $consumerId, + (int) (microtime(true) * 1000), // now + $receivedAt, + $queue->getQueueName(), + $message->getMessageId(), + $message->getCorrelationId(), + $message->getHeaders(), + $message->getProperties(), + $message->isRedelivered(), + ConsumedMessageStats::STATUS_ACK + )); +} +``` + +## Track consumer metrics + +Consumers are long running processes. It vital to know how many of them are running right now, how they perform, how much memory do they use and so. +This example shows how you can send such metrics. +Call this code from time to time between processing messages. + +```php +create('influxdb://127.0.0.1:8086?db=foo'); +$statsStorage->pushConsumerStats(new ConsumerStats( + 'consumerId', + (int) (microtime(true) * 1000), // now + $startedAt, + null, // finished at + true, // is started? + false, // is finished? + false, // is failed + ['foo'], // consume from queues + 123, // received messages + 120, // acknowledged messages + 1, // rejected messages + 1, // requeued messages + memory_get_usage(true), + sys_getloadavg()[0] +)); +``` + +## Consumption extension + +There is an extension `ConsumerMonitoringExtension` for Enqueue [QueueConsumer](quick_tour.md#consumption). +It could collect consumed messages and consumer stats for you. + +```php +create('influxdb://127.0.0.1:8086?db=foo'); + +$queueConsumer = new QueueConsumer($context, new ChainExtension([ + new ConsumerMonitoringExtension($statsStorage) +])); + +// bind + +// consume +``` + +## Enqueue Client Extension + +There is an extension ClientMonitoringExtension for Enqueue [Client](quick_tour.md#client) too. It could collect sent messages stats for you. + +## InfluxDB Storage + +Install additional packages: + +``` +composer req influxdb/influxdb-php:^1.14 +``` + +```php +create('influxdb://127.0.0.1:8086?db=foo'); +``` + +There are available options: + +``` +* 'host' => '127.0.0.1', +* 'port' => '8086', +* 'user' => '', +* 'password' => '', +* 'db' => 'enqueue', +* 'measurementSentMessages' => 'sent-messages', +* 'measurementConsumedMessages' => 'consumed-messages', +* 'measurementConsumers' => 'consumers', +* 'client' => null, +* 'retentionPolicy' => null, +``` + +You can pass InfluxDB\Client instance in `client` option. Otherwise, it will be created on first use according to other +options. + +If your InfluxDB\Client uses driver that implements InfluxDB\Driver\QueryDriverInterface, then database will be +automatically created for you if it doesn't exist. Default InfluxDB\Client will also do that. + +## Datadog storage + +Install additional packages: + +``` +composer req datadog/php-datadogstatsd:^1.3 +``` + +```php +create('datadog://127.0.0.1:8125'); +``` + +For best experience please adjust units and types in metric summary. + +Example dashboard: + +![Datadog monitoring](images/datadog_monitoring.png) + + +There are available options (and all available metrics): + +``` +* 'host' => '127.0.0.1', +* 'port' => '8125', +* 'batched' => true, // performance boost +* 'global_tags' => '', // should contain keys and values +* 'metric.messages.sent' => 'enqueue.messages.sent', +* 'metric.messages.consumed' => 'enqueue.messages.consumed', +* 'metric.messages.redelivered' => 'enqueue.messages.redelivered', +* 'metric.messages.failed' => 'enqueue.messages.failed', +* 'metric.consumers.started' => 'enqueue.consumers.started', +* 'metric.consumers.finished' => 'enqueue.consumers.finished', +* 'metric.consumers.failed' => 'enqueue.consumers.failed', +* 'metric.consumers.received' => 'enqueue.consumers.received', +* 'metric.consumers.acknowledged' => 'enqueue.consumers.acknowledged', +* 'metric.consumers.rejected' => 'enqueue.consumers.rejected', +* 'metric.consumers.requeued' => 'enqueue.consumers.requeued', +* 'metric.consumers.memoryUsage' => 'enqueue.consumers.memoryUsage', +``` + + +## WAMP (Web Socket Messaging Protocol) Storage + +Install additional packages: + +``` +composer req thruway/pawl-transport:^0.5.0 thruway/client:^0.5.0 +``` + +```php +create('wamp://127.0.0.1:9090?topic=stats'); +``` + +There are available options: + +``` +* 'host' => '127.0.0.1', +* 'port' => '9090', +* 'topic' => 'stats', +* 'max_retries' => 15, +* 'initial_retry_delay' => 1.5, +* 'max_retry_delay' => 300, +* 'retry_delay_growth' => 1.5, +``` + +## Symfony App + +You have to register some services in order to incorporate monitoring facilities into your Symfony application. + +```yaml +# config/packages/enqueue.yaml + +enqueue: + default: + transport: 'amqp://guest:guest@bar:5672/%2f' + monitoring: 'influxdb://127.0.0.1:8086?db=foo' + + another: + transport: 'amqp://guest:guest@foo:5672/%2f' + monitoring: 'wamp://127.0.0.1:9090?topic=stats' + client: ~ + + datadog: + transport: 'amqp://guest:guest@foo:5672/%2f' + monitoring: 'datadog://127.0.0.1:8125?batched=false' + client: ~ +``` + +[back to index](index.md) diff --git a/docs/monolog/send-messages-to-mq.md b/docs/monolog/send-messages-to-mq.md new file mode 100644 index 000000000..dbcb90eb5 --- /dev/null +++ b/docs/monolog/send-messages-to-mq.md @@ -0,0 +1,66 @@ +--- +layout: default +nav_exclude: true +--- +{% include support.md %} + +# Enqueue Monolog Handlers + +The package provides handlers for [Monolog](https://github.com/Seldaek/monolog). +These handler allows to send logs to MQ using any [queue-interop](https://github.com/queue-interop/queue-interop) compatible transports. + +## Installation + +You have to install monolog itself, queue interop handlers and one of [the transports](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md#transports). +For the simplicity we are going to install the filesystem based MQ. + +``` +composer require enqueue/monolog-queue-handler monolog/monolog enqueue/fs +``` + +## Usage + +```php +createContext(); + +// create a log channel +$log = new Logger('name'); +$log->pushHandler(new QueueInteropHandler($context)); + +// add records to the log +$log->warning('Foo'); +$log->error('Bar'); +``` + +the consumer may look like this: + +```php +createContext(); + +$consumer = new QueueConsumer($context); +$consumer->bindCallback('log', function(Message $message) { + echo $message->getBody().PHP_EOL; + + return Processor::ACK; +}); + +$consumer->consume(); + +``` + +[back to index](../index.md) diff --git a/docs/null_transport.md b/docs/null_transport.md deleted file mode 100644 index 622bdea8b..000000000 --- a/docs/null_transport.md +++ /dev/null @@ -1,19 +0,0 @@ -# NULL transport - -This a special transport implementation, kind of stub. -It does not send nor receive anything. -Useful in tests for example. - - -## Create context - -```php -createContext(); -``` - -[back to index](index.md) \ No newline at end of file diff --git a/docs/quick_tour.md b/docs/quick_tour.md index b51c63883..4e6bfccec 100644 --- a/docs/quick_tour.md +++ b/docs/quick_tour.md @@ -1,51 +1,60 @@ +--- +layout: default +title: Quick tour +nav_order: 2 +--- +{% include support.md %} + # Quick tour - + * [Transport](#transport) * [Consumption](#consumption) * [Remote Procedure Call (RPC)](#remote-procedure-call-rpc) * [Client](#client) * [Cli commands](#cli-commands) +* [Monitoring](#monitoring) +* [Symfony bundle](#symfony) ## Transport -The transport layer or PSR (Enqueue message service) is a Message Oriented Middleware for sending messages between two or more clients. -It is a messaging component that allows applications to create, send, receive, and read messages. +The transport layer or PSR (Enqueue message service) is a Message Oriented Middleware for sending messages between two or more clients. +It is a messaging component that allows applications to create, send, receive, and read messages. It allows the communication between different components of a distributed application to be loosely coupled, reliable, and asynchronous. -PSR is inspired by JMS (Java Message Service). We tried to be as close as possible to [JSR 914](https://docs.oracle.com/javaee/7/api/javax/jms/package-summary.html) specification. +PSR is inspired by JMS (Java Message Service). We tried to stay as close as possible to the [JSR 914](https://docs.oracle.com/javaee/7/api/javax/jms/package-summary.html) specification. For now it supports [AMQP](https://www.rabbitmq.com/tutorials/amqp-concepts.html) and [STOMP](https://stomp.github.io/) message queue protocols. -You can connect to many modern brokers such as [RabbitMQ](https://www.rabbitmq.com/), [ActiveMQ](http://activemq.apache.org/) and others. +You can connect to many modern brokers such as [RabbitMQ](https://www.rabbitmq.com/), [ActiveMQ](http://activemq.apache.org/) and others. Produce a message: ```php createContext(); +$context = $connectionFactory->createContext(); -$destination = $psrContext->createQueue('foo'); +$destination = $context->createQueue('foo'); //$destination = $context->createTopic('foo'); -$message = $psrContext->createMessage('Hello world!'); +$message = $context->createMessage('Hello world!'); -$psrContext->createProducer()->send($destination, $message); +$context->createProducer()->send($destination, $message); ``` Consume a message: ```php createContext(); +$context = $connectionFactory->createContext(); -$destination = $psrContext->createQueue('foo'); +$destination = $context->createQueue('foo'); //$destination = $context->createTopic('foo'); -$consumer = $psrContext->createConsumer($destination); +$consumer = $context->createConsumer($destination); $message = $consumer->receive(); @@ -55,41 +64,42 @@ $consumer->acknowledge($message); // $consumer->reject($message); ``` -## Consumption +## Consumption -Consumption is a layer build on top of a transport functionality. -The goal of the component is to simply message consumption. -The `QueueConsumer` is main piece of the component it allows bind message processors (or callbacks) to queues. -The `consume` method starts the consumption process which last as long as it is interrupted. +Consumption is a layer built on top of a transport functionality. +The goal of the component is to simply consume messages. +The `QueueConsumer` is main piece of the component it allows binding of message processors (or callbacks) to queues. +The `consume` method starts the consumption process which last as long as it is not interrupted. ```php bindCallback('foo_queue', function(Message $message) { + // process message -$queueConsumer->bind('foo_queue', function(Message $message) { - // process messsage - return Processor::ACK; }); -$queueConsumer->bind('bar_queue', function(Message $message) { - // process messsage - +$queueConsumer->bindCallback('bar_queue', function(Message $message) { + // process message + return Processor::ACK; }); $queueConsumer->consume(); ``` -There are bunch of [extensions](consumption/extensions.md) available. -This is an example of how you can add them. +There are bunch of [extensions](consumption/extensions.md) available. +This is an example of how you can add them. The `SignalExtension` provides support of process signals, whenever you send SIGTERM for example it will correctly managed. -The `LimitConsumptionTimeExtension` interrupts the consumption after given time. +The `LimitConsumptionTimeExtension` interrupts the consumption after given time. ```php createQueue('foo'); -$message = $psrContext->createMessage('Hi there!'); +$queue = $context->createQueue('foo'); +$message = $context->createMessage('Hi there!'); -$rpcClient = new RpcClient($psrContext); +$rpcClient = new RpcClient($context); $promise = $rpcClient->callAsync($queue, $message, 1); -$replyMessage = $promise->getMessage(); +$replyMessage = $promise->receive(); ``` -There is also extensions for the consumption component. +There is also extensions for the consumption component. It simplifies a server side of RPC. ```php bind('foo', function(Message $message, Context $context) { +$queueConsumer->bindCallback('foo', function(Message $message, Context $context) { $replyMessage = $context->createMessage('Hello'); - + return Result::reply($replyMessage); }); @@ -156,39 +166,87 @@ $queueConsumer->consume(); ## Client It provides an easy to use high level abstraction. -The goal of the component is hide as much as possible low level details so you can concentrate on things that really matters. -For example, It configure a broker for you by creating queuest, exchanges and bind them. -It provides easy to use services for producing and processing messages. +The goal of the component is to hide as much as possible low level details so you can concentrate on things that really matter. +For example, it configures a broker for you by creating queues, exchanges and bind them. +It provides easy to use services for producing and processing messages. It supports unified format for setting message expiration, delay, timestamp, correlation id. -It supports message bus so different applications can talk to each other. - -Here's an example of how you can send and consume messages. - +It supports [message bus](http://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageBus.html) so different applications can talk to each other. + +Here's an example of how you can send and consume **event messages**. + ```php bind('foo_topic', function (Message $message) { - // process message +// composer require enqueue/fs +$client = new SimpleClient('file://foo/bar'); +$client->bindTopic('a_foo_topic', function(Message $message) { + echo $message->getBody().PHP_EOL; - return Processor::ACK; + // your event processor logic here }); -$client->send('foo_topic', 'Hello there!'); +$client->setupBroker(); + +$client->sendEvent('a_foo_topic', 'message'); -// in another process you can consume messages. +// this is a blocking call, it'll consume message until it is interrupted $client->consume(); ``` +and **command messages**: + +```php +bindCommand('bar_command', function(Message $message) { + // your bar command processor logic here +}); + +$client->bindCommand('baz_reply_command', function(Message $message, Context $context) { + // your baz reply command processor logic here + + return Result::reply($context->createMessage('theReplyBody')); +}); + +$client->setupBroker(); + +// It is sent to one consumer. +$client->sendCommand('bar_command', 'aMessageData'); + +// It is possible to get reply +$promise = $client->sendCommand('bar_command', 'aMessageData', true); + +// you can send several commands and only after start getting replies. + +$replyMessage = $promise->receive(2000); // 2 sec + +// this is a blocking call, it'll consume message until it is interrupted +$client->consume([new ReplyExtension()]); +``` + +Read more about events and commands [here](client/quick_tour.md#produce-message). + ## Cli commands -The library provides handy commands out of the box. -They all build on top of [Symfony Console component](http://symfony.com/doc/current/components/console.html). +The library provides handy commands out of the box. +They all build on top of [Symfony Console component](http://symfony.com/doc/current/components/console.html). The most useful is a consume command. There are two of them one from consumption component and the other from client one. Let's see how you can use consumption one: @@ -199,17 +257,17 @@ Let's see how you can use consumption one: // app.php use Symfony\Component\Console\Application; -use Enqueue\Psr\Message; +use Interop\Queue\Message; use Enqueue\Consumption\QueueConsumer; -use Enqueue\Symfony\Consumption\ConsumeMessagesCommand; +use Enqueue\Symfony\Consumption\SimpleConsumeCommand; /** @var QueueConsumer $queueConsumer */ -$queueConsumer->bind('a_queue', function(Message $message) { - // process message +$queueConsumer->bindCallback('a_queue', function(Message $message) { + // process message }); -$consumeCommand = new ConsumeMessagesCommand($queueConsumer); +$consumeCommand = new SimpleConsumeCommand($queueConsumer); $consumeCommand->setName('consume'); $app = new Application(); @@ -218,9 +276,17 @@ $app->run(); ``` and starts the consumption from the console: - + ```bash $ app.php consume ``` +## Monitoring + +There is a tool that can track sent\consumed messages as well as consumer performance. Read more [here](monitoring.md) + [back to index](index.md) + +## Symfony + +Read more [here](bundle/quick_tour.md) about using Enqueue as a Symfony Bundle. diff --git a/docs/stomp_transport.md b/docs/stomp_transport.md deleted file mode 100644 index 03a03ceb3..000000000 --- a/docs/stomp_transport.md +++ /dev/null @@ -1,68 +0,0 @@ -# STOMP transport - -* [Send message to topic](#send-message-to-topic) -* [Send message to queue](#send-message-to-queue) -* [Consume message](#consume-message) - -## Create context - -```php - '127.0.0.1', - 'port' => 61613, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', -]); - -$psrContext = $connectionFactory->createContext(); -``` - -## Send message to topic - -```php -createMessage('Hello world!'); - -$fooTopic = $psrContext->createTopic('foo'); - -$psrContext->createProducer()->send($fooTopic, $message); -``` - -## Send message to queue - -```php -createMessage('Hello world!'); - -$fooQueue = $psrContext->createQueue('foo'); - -$psrContext->createProducer()->send($fooQueue, $message); -``` - -## Consume message: - -```php -createQueue('foo'); - -$consumer = $psrContext->createConsumer($fooQueue); - -$message = $consumer->receive(); - -// process a message - -$consumer->acknowledge($message); -// $consumer->reject($message); -``` - -[back to index](index.md) \ No newline at end of file diff --git a/docs/transport/amqp.md b/docs/transport/amqp.md new file mode 100644 index 000000000..f398f0043 --- /dev/null +++ b/docs/transport/amqp.md @@ -0,0 +1,282 @@ +--- +layout: default +title: AMQP +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# AMQP transport + +Implements [AMQP specifications](https://www.rabbitmq.com/specification.html) and implements [amqp interop](https://github.com/queue-interop/amqp-interop) interfaces. +Build on top of [php amqp extension](https://github.com/pdezwart/php-amqp). + +Drawbacks: +* [heartbeats will not work properly](https://github.com/pdezwart/php-amqp#persistent-connection) +* [signals will not be properly handled](https://github.com/pdezwart/php-amqp#keeping-track-of-the-workers) + +Parts: +* [Installation](#installation) +* [Create context](#create-context) +* [Declare topic](#declare-topic) +* [Declare queue](#declare-queue) +* [Bind queue to topic](#bind-queue-to-topic) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send priority message](#send-priority-message) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) +* [Purge queue messages](#purge-queue-messages) + +## Installation + +_**Warning**: You need amqp extension of at least 1.9.3. Here's how you can [compile](https://github.com/php-enqueue/enqueue-dev/blob/09d209447b9dbdf118bff7d983fcb8b0f919e789/docker/Dockerfile#L8) the extension from the [source code](https://github.com/pdezwart/php-amqp)._ + +```bash +$ composer require enqueue/amqp-ext +``` + +## Create context + +```php + 'example.com', + 'port' => 1000, + 'vhost' => '/', + 'user' => 'user', + 'pass' => 'pass', + 'persisted' => false, +]); + +// same as above but given as DSN string +$factory = new AmqpConnectionFactory('amqp://user:pass@example.com:10000/%2f'); + +// SSL or secure connection +$factory = new AmqpConnectionFactory([ + 'dsn' => 'amqps:', + 'ssl_cacert' => '/path/to/cacert.pem', + 'ssl_cert' => '/path/to/cert.pem', + 'ssl_key' => '/path/to/key.pem', +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp:')->createContext(); +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp+ext:')->createContext(); +``` + +## Declare topic. + +Declare topic operation creates a topic on a broker side. + +```php +createTopic('foo'); +$fooTopic->setType(AmqpTopic::TYPE_FANOUT); +$context->declareTopic($fooTopic); + +// to remove topic use delete topic method +//$context->deleteTopic($fooTopic); +``` + +## Declare queue. + +Declare queue operation creates a queue on a broker side. + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$context->declareQueue($fooQueue); + +// to remove queue use delete queue method +//$context->deleteQueue($fooQueue); +``` + +## Bind queue to topic + +Connects a queue to the topic. So messages from that topic comes to the queue and could be processed. + +```php +bind(new AmqpBind($fooTopic, $fooQueue)); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send priority message + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$fooQueue->setArguments(['x-max-priority' => 10]); +$context->declareQueue($fooQueue); + +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setPriority(5) // the higher priority the sooner a message gets to a consumer + // + ->send($fooQueue, $message) +; +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +AMQP specification says nothing about message delaying hence the producer throws `DeliveryDelayNotSupportedException`. +Though the producer (and the context) accepts a delivery delay strategy and if it is set it uses it to send delayed message. +The `enqueue/amqp-tools` package provides two RabbitMQ delay strategies, to use them you have to install that package + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setDelayStrategy(new RabbitMqDlxDelayStrategy()) + ->setDeliveryDelay(5000) // 5 sec + + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +## Purge queue messages: + +```php +createQueue('aQueue'); + +$context->purgeQueue($queue); +``` + +[back to index](../index.md) diff --git a/docs/transport/amqp_bunny.md b/docs/transport/amqp_bunny.md new file mode 100644 index 000000000..066a023cd --- /dev/null +++ b/docs/transport/amqp_bunny.md @@ -0,0 +1,269 @@ +--- +layout: default +title: AMQP Bunny +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# AMQP transport + +Implements [AMQP specifications](https://www.rabbitmq.com/specification.html) and implements [amqp interop](https://github.com/queue-interop/amqp-interop) interfaces. +Build on top of [bunny lib](https://github.com/jakubkulhan/bunny). + +* [Installation](#installation) +* [Create context](#create-context) +* [Declare topic](#declare-topic) +* [Declare queue](#declare-queue) +* [Bind queue to topic](#bind-queue-to-topic) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send priority message](#send-priority-message) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) +* [Purge queue messages](#purge-queue-messages) + +## Installation + +```bash +$ composer require enqueue/amqp-bunny +``` + +## Create context + +```php + 'example.com', + 'port' => 1000, + 'vhost' => '/', + 'user' => 'user', + 'pass' => 'pass', + 'persisted' => false, +]); + +// same as above but given as DSN string +$factory = new AmqpConnectionFactory('amqp://user:pass@example.com:10000/%2f'); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp:')->createContext(); +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp+bunny:')->createContext(); +``` + +## Declare topic. + +Declare topic operation creates a topic on a broker side. + +```php +createTopic('foo'); +$fooTopic->setType(AmqpTopic::TYPE_FANOUT); +$context->declareTopic($fooTopic); + +// to remove topic use delete topic method +//$context->deleteTopic($fooTopic); +``` + +## Declare queue. + +Declare queue operation creates a queue on a broker side. + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$context->declareQueue($fooQueue); + +// to remove topic use delete queue method +//$context->deleteQueue($fooQueue); +``` + +## Bind queue to topic + +Connects a queue to the topic. So messages from that topic comes to the queue and could be processed. + +```php +bind(new AmqpBind($fooTopic, $fooQueue)); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send priority message + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$fooQueue->setArguments(['x-max-priority' => 10]); +$context->declareQueue($fooQueue); + +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setPriority(5) // the higher priority the sooner a message gets to a consumer + // + ->send($fooQueue, $message) +; +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +AMQP specification says nothing about message delaying hence the producer throws `DeliveryDelayNotSupportedException`. +Though the producer (and the context) accepts a delivery delay strategy and if it is set it uses it to send delayed message. +The `enqueue/amqp-tools` package provides two RabbitMQ delay strategies, to use them you have to install that package + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setDelayStrategy(new RabbitMqDlxDelayStrategy()) + ->setDeliveryDelay(5000) // 5 sec + + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +## Purge queue messages: + +```php +createQueue('aQueue'); + +$context->purgeQueue($queue); +``` + +[back to index](../index.md) diff --git a/docs/transport/amqp_lib.md b/docs/transport/amqp_lib.md new file mode 100644 index 000000000..288779a55 --- /dev/null +++ b/docs/transport/amqp_lib.md @@ -0,0 +1,347 @@ +--- +layout: default +title: AMQP Lib +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# AMQP transport + +Implements [AMQP specifications](https://www.rabbitmq.com/specification.html) and implements [amqp interop](https://github.com/queue-interop/amqp-interop) interfaces. +Build on top of [php amqp lib](https://github.com/php-amqplib/php-amqplib). + +Features: +* Configure with DSN string +* Delay strategies out of the box +* Interchangeable with other AMQP Interop implementations +* Fixes AMQPIOWaitException when signal is sent. +* More reliable heartbeat implementations. +* Supports Subscription consumer + +Parts: +* [Installation](#installation) +* [Create context](#create-context) +* [Declare topic](#declare-topic) +* [Declare queue](#decalre-queue) +* [Bind queue to topic](#bind-queue-to-topic) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send priority message](#send-priority-message) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) +* [Purge queue messages](#purge-queue-messages) +* [Long running task and heartbeat and timeouts](#long-running-task-and-heartbeat-and-timeouts) + +## Installation + +```bash +$ composer require enqueue/amqp-lib +``` + +## Create context + +```php + 'example.com', + 'port' => 1000, + 'vhost' => '/', + 'user' => 'user', + 'pass' => 'pass', + 'persisted' => false, +]); + +// same as above but given as DSN string +$factory = new AmqpConnectionFactory('amqp://user:pass@example.com:10000/%2f'); + +// SSL or secure connection +$factory = new AmqpConnectionFactory([ + 'dsn' => 'amqps:', + 'ssl_cacert' => '/path/to/cacert.pem', + 'ssl_cert' => '/path/to/cert.pem', + 'ssl_key' => '/path/to/key.pem', +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp:')->createContext(); +$context = (new \Enqueue\ConnectionFactoryFactory())->create('amqp+lib:')->createContext(); +``` + +## Declare topic. + +Declare topic operation creates a topic on a broker side. + +```php +createTopic('foo'); +$fooTopic->setType(AmqpTopic::TYPE_FANOUT); +$context->declareTopic($fooTopic); + +// to remove topic use delete topic method +//$context->deleteTopic($fooTopic); +``` + +## Declare queue. + +Declare queue operation creates a queue on a broker side. + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$context->declareQueue($fooQueue); + +// to remove topic use delete queue method +//$context->deleteQueue($fooQueue); +``` + +## Bind queue to topic + +Connects a queue to the topic. So messages from that topic comes to the queue and could be processed. + +```php +bind(new AmqpBind($fooTopic, $fooQueue)); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send priority message + +```php +createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); +$fooQueue->setArguments(['x-max-priority' => 10]); +$context->declareQueue($fooQueue); + +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setPriority(5) // the higher priority the sooner a message gets to a consumer + // + ->send($fooQueue, $message) +; +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +AMQP specification says nothing about message delaying hence the producer throws `DeliveryDelayNotSupportedException`. +Though the producer (and the context) accepts a delivery delay strategy and if it is set it uses it to send delayed message. +The `enqueue/amqp-tools` package provides two RabbitMQ delay strategies, to use them you have to install that package + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setDelayStrategy(new RabbitMqDlxDelayStrategy()) + ->setDeliveryDelay(5000) // 5 sec + + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +## Purge queue messages: + +```php +createQueue('aQueue'); + +$context->purgeQueue($queue); +``` + +## Long running task and heartbeat and timeouts + +AMQP relies on heartbeat feature to make sure consumer is still there. +Basically consumer is expected to send heartbeat frames from time to time to RabbitMQ broker so the broker does not close the connection. +It is not possible to implement heartbeat feature in PHP, due to its synchronous nature. +You could read more about the issues in post: [Keeping RabbitMQ connections alive in PHP](https://blog.mollie.com/keeping-rabbitmq-connections-alive-in-php-b11cb657d5fb). + +`enqueue/amqp-lib` address the issue by registering heartbeat call as a [tick callbacks](http://php.net/manual/en/function.register-tick-function.php). +To make it work you have to wrapp your long running task by `declare(ticks=1) {}`. +The number of ticks could be adjusted to your needs. +Calling it at every tick is not good. + +Please note that it does not fix heartbeat issue if you spent most of the time on IO operation. + +Example: + +```php +createContext(); + +$queue = $context->createQueue('a_queue'); +$consumer = $context->createConsumer($queue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($consumer, function(AmqpMessage $message, AmqpConsumer $consumer) { + // ticks number should be adjusted. + declare(ticks=1) { + foreach (fetchHugeSet() as $item) { + // cycle does something for a long time, much longer than amqp heartbeat. + } + } + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(10000); + + +function fetchHugeSet(): array {}; +``` + +Fixes partly `Invalid frame type 65` issue. + +``` +Error: Uncaught PhpAmqpLib\Exception\AMQPRuntimeException: Invalid frame type 65 in /some/path/vendor/php-amqplib/php-amqplib/PhpAmqpLib/Connection/AbstractConnection.php:528 +``` + +Fixes partly `Broken pipe or closed connection` issue. + +``` +PHP Fatal error: Uncaught exception 'PhpAmqpLib\Exception\AMQPRuntimeException' with message 'Broken pipe or closed connection' in /some/path/vendor/php-amqplib/php-amqplib/PhpAmqpLib/Wire/IO/StreamIO.php:190 +``` + +[back to index](../index.md) diff --git a/docs/transport/dbal.md b/docs/transport/dbal.md new file mode 100644 index 000000000..559414a74 --- /dev/null +++ b/docs/transport/dbal.md @@ -0,0 +1,183 @@ +--- +layout: default +title: DBAL +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Doctrine DBAL transport + +The transport uses [Doctrine DBAL](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/) library and SQL like server as a broker. +It creates a table there. Pushes and pops messages to\from that table. + +* [Installation](#installation) +* [Init database](#init-database) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) + +## Installation + +```bash +$ composer require enqueue/dbal +``` + +## Create context + +* With config (a connection is created internally): + +```php +createContext(); +``` + +* With existing connection: + +```php + 'default', +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('mysql:')->createContext(); +``` + +## Init database + +At first time you have to create a table where your message will live. There is a handy methods for this `createDataBaseTable` on the context. +Please pay attention to that the database has to be created manually. + +```php +createDataBaseTable(); +``` + +## Send message to topic + +```php +createTopic('aTopic'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$psrContext->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +```php +createMessage('Hello world!'); + +$psrContext->createProducer() + ->setDeliveryDelay(5000) // 5 sec + // + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createQueue('aQueue'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +//$consumer->reject($message); +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +[back to index](../index.md) diff --git a/docs/transport/filesystem.md b/docs/transport/filesystem.md new file mode 100644 index 000000000..768ee50b7 --- /dev/null +++ b/docs/transport/filesystem.md @@ -0,0 +1,131 @@ +--- +layout: default +title: Filesystem +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Filesystem transport + +Use files on local filesystem as queues. +It creates a file per queue\topic. +A message is a line inside the file. +**Limitations** It works only in auto ack mode hence If consumer crashes the message is lost. Local by nature therefor messages are not visible on other servers. + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send expiration message](#send-expiration-message) +* [Consume message](#consume-message) +* [Purge queue messages](#purge-queue-messages) + +## Installation + +```bash +$ composer require enqueue/fs +``` + +## Create context + +```php + '/path/to/queue/dir', + 'pre_fetch_count' => 1, +]); + +$context = $connectionFactory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('file:')->createContext(); +``` + +## Send message to topic + +```php +createTopic('aTopic'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send expiration message + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Consume message: + +```php +createQueue('aQueue'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Purge queue messages: + +```php +createQueue('aQueue'); + +$context->purge($fooQueue); +``` + +[back to index](../index.md) diff --git a/docs/transport/gearman.md b/docs/transport/gearman.md new file mode 100644 index 000000000..8ed6da021 --- /dev/null +++ b/docs/transport/gearman.md @@ -0,0 +1,97 @@ +--- +layout: default +title: Gearman +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Gearman transport + +The transport uses [Gearman](http://gearman.org/) job manager. +The transport uses [Gearman PHP extension](http://php.net/manual/en/book.gearman.php) internally. + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Consume message](#consume-message) + +## Installation + +```bash +$ composer require enqueue/gearman +``` + + +## Create context + +```php + 'example', + 'port' => 5555 +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('gearman:')->createContext(); +``` + +## Send message to topic + +```php +createTopic('aTopic'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Consume message: + +```php +createQueue('aQueue'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(2000); // wait for 2 seconds + +$message = $consumer->receiveNoWait(); // fetch message or return null immediately + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +[back to index](../index.md) diff --git a/docs/transport/gps.md b/docs/transport/gps.md new file mode 100644 index 000000000..b56f5c949 --- /dev/null +++ b/docs/transport/gps.md @@ -0,0 +1,88 @@ +--- +layout: default +title: GPS +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Google Pub Sub transport + +A transport for [Google Pub Sub](https://cloud.google.com/pubsub/docs/) cloud MQ. +It uses internally official google sdk library [google/cloud-pubsub](https://packagist.org/packages/google/cloud-pubsub) + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Consume message](#consume-message) + +## Installation + +```bash +$ composer require enqueue/gps +``` + +## Create context + +To enable the Google Cloud Pub/Sub Emulator, set the `PUBSUB_EMULATOR_HOST` environment variable. +There is a handy docker container [google/cloud-sdk](https://hub.docker.com/r/google/cloud-sdk/). + +```php +createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('gps:')->createContext(); +``` + +## Send message to topic + +Before you can send message you have to declare a topic. +The operation creates a topic on a broker side. +Google allows messages to be sent only to topic. + +```php +createTopic('foo'); +$message = $context->createMessage('Hello world!'); + +$context->declareTopic($fooTopic); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Consume message: + +Before you can consume message you have to subscribe a queue to the topic. +Google does not allow consuming message from the topic directly. + +```php +createTopic('foo'); +$fooQueue = $context->createQueue('foo'); + +$context->subscribe($fooTopic, $fooQueue); + +$consumer = $context->createConsumer($fooQueue); +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +[back to index](../index.md) diff --git a/docs/transport/index.md b/docs/transport/index.md new file mode 100644 index 000000000..47da348c4 --- /dev/null +++ b/docs/transport/index.md @@ -0,0 +1,11 @@ +--- +layout: default +title: Transports +nav_order: 3 +has_children: true +permalink: /transport +--- + +{:toc} + +[Feature Comparison Table](../client/supported_brokers.md#transport-features) diff --git a/docs/transport/kafka.md b/docs/transport/kafka.md new file mode 100644 index 000000000..1009034ba --- /dev/null +++ b/docs/transport/kafka.md @@ -0,0 +1,188 @@ +--- +layout: default +title: Kafka +parent: Transports +nav_order: 3 +--- + +{% include support.md %} + +# Kafka transport + +The transport uses [Kafka](https://kafka.apache.org/) streaming platform as a MQ broker. + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Consume message](#consume-message) +* [Serialize message](#serialize-message) +* [Change offset](#change-offset) + +## Installation + +```bash +$ composer require enqueue/rdkafka +``` + +## Create context + +```php + [ + 'group.id' => uniqid('', true), + 'metadata.broker.list' => 'example.com:1000', + 'enable.auto.commit' => 'false', + ], + 'topic' => [ + 'auto.offset.reset' => 'beginning', + ], +]); + +$context = $connectionFactory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('kafka:')->createContext(); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$fooTopic = $context->createTopic('foo'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$fooQueue = $context->createQueue('foo'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Consume message: + +```php +createQueue('foo'); + +$consumer = $context->createConsumer($fooQueue); + +// Enable async commit to gain better performance (true by default since version 0.9.9). +//$consumer->setCommitAsync(true); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Serialize message + +By default the transport serializes messages to json format but you might want to use another format such as [Apache Avro](https://avro.apache.org/docs/1.2.0/). +For that you have to implement Serializer interface and set it to the context, producer or consumer. +If a serializer set to context it will be injected to all consumers and producers created by the context. + +```php +setSerializer(new FooSerializer()); +``` + +## Change offset + +By default consumers starts from the beginning of the topic and updates the offset while you are processing messages. +There is an ability to change the current offset. + +```php +createQueue('foo'); + +$consumer = $context->createConsumer($fooQueue); +$consumer->setOffset(123); + +$message = $consumer->receive(2000); +``` + +## Usage with Symfony bundle + +Set your enqueue to use rdkafka as your transport + +```yaml +# app/config/config.yml + +enqueue: + default: + transport: "rdkafka:" + client: ~ +``` + +You can also you extended configuration to pass additional options, if you don't want to pass them via DSN string or +need to pass specific options. Since rdkafka uses librdkafka (being basically a wrapper around it) most configuration +options are identical to those found at https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md. + +```yaml +# app/config/config.yml + +enqueue: + default: + transport: + dsn: "rdkafka://" + global: + ### Make sure this is unique for each application / consumer group and does not change + ### Otherwise, Kafka won't be able to track your last offset and will always start according to + ### `auto.offset.reset` setting. + ### See Kafka documentation regarding `group.id` property if you want to know more + group.id: 'foo-app' + metadata.broker.list: 'example.com:1000' + topic: + auto.offset.reset: beginning + ### Commit async is true by default since version 0.9.9. + ### It is suggested to set it to true in earlier versions since otherwise consumers become extremely slow, + ### waiting for offset to be stored on Kafka before continuing. + commit_async: true + client: ~ +``` + +[back to index](index.md) diff --git a/docs/transport/mongodb.md b/docs/transport/mongodb.md new file mode 100644 index 000000000..9e97872aa --- /dev/null +++ b/docs/transport/mongodb.md @@ -0,0 +1,184 @@ +--- +layout: default +title: MongoDB +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Enqueue Mongodb message queue transport + +Allows to use [MongoDB](https://www.mongodb.com/) as a message queue broker. + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send priority message](#send-priority-message) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) + +## Installation + +```bash +$ composer require enqueue/mongodb +``` + +## Create context + +```php + 'mongodb://localhost:27017/db_name', + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + 'polling_interval' => '1000', +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('mongodb:')->createContext(); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send priority message + +```php +createQueue('foo'); + +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setPriority(5) // the higher priority the sooner a message gets to a consumer + // + ->send($fooQueue, $message) +; +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setDeliveryDelay(5000) // 5 sec + + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + $consumer->acknowledge($message); + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +[back to index](../index.md) diff --git a/docs/transport/null.md b/docs/transport/null.md new file mode 100644 index 000000000..aa77b5e72 --- /dev/null +++ b/docs/transport/null.md @@ -0,0 +1,35 @@ +--- +layout: default +title: "Null" +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# NULL transport + +This a special transport implementation, kind of stub. +It does not send nor receive anything. +Useful in tests for example. + +* [Installation](#installation) +* [Create context](#create-context) + +## Installation + +```bash +$ composer require enqueue/null +``` + +## Create context + +```php +createContext(); +``` + +[back to index](../index.md) diff --git a/docs/transport/pheanstalk.md b/docs/transport/pheanstalk.md new file mode 100644 index 000000000..9d3af572b --- /dev/null +++ b/docs/transport/pheanstalk.md @@ -0,0 +1,97 @@ +--- +layout: default +title: Pheanstalk +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Beanstalk (Pheanstalk) transport + +The transport uses [Beanstalkd](http://kr.github.io/beanstalkd/) job manager. +The transport uses [Pheanstalk](https://github.com/pda/pheanstalk) library internally. + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Consume message](#consume-message) + +## Installation + +```bash +$ composer require enqueue/pheanstalk +``` + + +## Create context + +```php + 'example', + 'port' => 5555 +]); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('beanstalk:')->createContext(); +``` + +## Send message to topic + +```php +createTopic('aTopic'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Consume message: + +```php +createQueue('aQueue'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(2000); // wait for 2 seconds + +$message = $consumer->receiveNoWait(); // fetch message or return null immediately + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +[back to index](../index.md) diff --git a/docs/transport/redis.md b/docs/transport/redis.md new file mode 100644 index 000000000..c357fd083 --- /dev/null +++ b/docs/transport/redis.md @@ -0,0 +1,239 @@ +--- +layout: default +title: Redis +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Redis transport + +The transport uses [Redis](https://redis.io/) as a message broker. +It creates a collection (a queue or topic) there. Pushes messages to the tail of the collection and pops from the head. +The transport works with [phpredis](https://github.com/phpredis/phpredis) php extension or [predis](https://github.com/nrk/predis) library. +Make sure you installed either of them + +Features: +* Configure with DSN string +* Delay strategies out of the box +* Recovery&Redelivery support +* Expiration support +* Delaying support +* Interchangeable with other Queue Interop implementations +* Supports Subscription consumer + +Parts: +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Send expiration message](#send-expiration-message) +* [Send delayed message](#send-delayed-message) +* [Consume message](#consume-message) +* [Delete queue (purge messages)](#delete-queue-purge-messages) +* [Delete topic (purge messages)](#delete-topic-purge-messages) +* [Connect Heroku Redis](#connect-heroku-redis) + +## Installation + +* With php redis extension: + +```bash +$ apt-get install php-redis +$ composer require enqueue/redis +``` + +* With predis library: + +```bash +$ composer require enqueue/redis predis/predis:^1 +``` + +## Create context + +* With php redis extension: + +```php + 'example.com', + 'port' => 1000, + 'scheme_extensions' => ['phpredis'], +]); + +// same as above but given as DSN string +$factory = new RedisConnectionFactory('redis+phpredis://example.com:1000'); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('redis:')->createContext(); + +// pass redis instance directly +$redis = new \Enqueue\Redis\PhpRedis([ /** redis connection options */ ]); +$redis->connect(); + +// Secure\TLS connection. Works only with predis library. Note second "S" in scheme. +$factory = new RedisConnectionFactory('rediss+predis://user:pass@host/0'); + +$factory = new RedisConnectionFactory($redis); +``` + +* With predis library: + +```php + 'localhost', + 'port' => 6379, + 'scheme_extensions' => ['predis'], +]); + +$context = $connectionFactory->createContext(); +``` + +* With predis and custom [options](https://github.com/nrk/predis/wiki/Client-Options): + +It gives you more control over vendor specific features. + +```php + 'localhost', + 'port' => 6379, + 'predis_options' => [ + 'prefix' => 'ns:' + ] +]; + +$redis = new PRedis($config); + +$factory = new RedisConnectionFactory($redis); +``` + +## Send message to topic + +```php +createTopic('aTopic'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createQueue('aQueue'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send expiration message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setTimeToLive(60000) // 60 sec + // + ->send($fooQueue, $message) +; +``` + +## Send delayed message + +```php +createMessage('Hello world!'); + +$context->createProducer() + ->setDeliveryDelay(5000) // 5 sec + + ->send($fooQueue, $message) +; +```` + +## Consume message: + +```php +createQueue('aQueue'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +//$consumer->reject($message); +``` + +## Delete queue (purge messages): + +```php +createQueue('aQueue'); + +$context->deleteQueue($fooQueue); +``` + +## Delete topic (purge messages): + +```php +createTopic('aTopic'); + +$context->deleteTopic($fooTopic); +``` + +## Connect Heroku Redis + +[Heroku Redis](https://devcenter.heroku.com/articles/heroku-redis) describes how to setup Redis instance on Heroku. +To use it with Enqueue Redis you have to pass REDIS_URL to RedisConnectionFactory constructor. + +```php + 'aKey', + 'secret' => 'aSecret', + 'region' => 'aRegion', + + // or you can segregate options using prefixes "sns_", "sqs_" + 'key' => 'aKey', // common option for both SNS and SQS + 'sns_region' => 'aSnsRegion', // SNS transport option + 'sqs_region' => 'aSqsRegion', // SQS transport option +]); + +// same as above but given as DSN string. You may need to url encode secret if it contains special char (like +) +$factory = new SnsQsConnectionFactory('snsqs:?key=aKey&secret=aSecret®ion=aRegion'); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('snsqs:')->createContext(); +``` + +## Declare topic, queue and bind them together + +Declare topic, queue operation creates a topic, queue on a broker side. +Bind creates connection between topic and queue. You publish message to +the topic and topic sends message to each queue connected to the topic. + + +```php +createTopic('in'); +$context->declareTopic($inTopic); + +$out1Queue = $context->createQueue('out1'); +$context->declareQueue($out1Queue); + +$out2Queue = $context->createQueue('out2'); +$context->declareQueue($out2Queue); + +$context->bind($inTopic, $out1Queue); +$context->bind($inTopic, $out2Queue); + +// to remove topic/queue use deleteTopic/deleteQueue method +//$context->deleteTopic($inTopic); +//$context->deleteQueue($out1Queue); +//$context->unbind(inTopic, $out1Queue); +``` + +## Send message to topic + +```php +createTopic('in'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($inTopic, $message); +``` + +## Send message to queue + +You can bypass topic and publish message directly to the queue + +```php +createQueue('foo'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + + +## Consume message: + +```php +createQueue('out1'); +$consumer = $context->createConsumer($out1Queue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Purge queue messages: + +```php +createQueue('foo'); + +$context->purgeQueue($fooQueue); +``` + +## Queue from another AWS account + +SQS allows to use queues from another account. You could set it globally for all queues via option `queue_owner_aws_account_id` or +per queue using `SnsQsQueue::setQueueOwnerAWSAccountId` method. + +```php +createContext(); + +// per queue. +$queue = $context->createQueue('foo'); +$queue->setQueueOwnerAWSAccountId('awsAccountId'); +``` + +## Multi region examples + +Enqueue SNSQS provides a generic multi-region support. This enables users to specify which AWS Region to send a command to by setting region on SnsQsQueue. +If not specified the default region is used. + +```php +createContext(); + +$queue = $context->createQueue('foo'); +$queue->setRegion('us-west-2'); + +// the request goes to US West (Oregon) Region +$context->declareQueue($queue); +``` + +[back to index](../index.md) diff --git a/docs/transport/sqs.md b/docs/transport/sqs.md new file mode 100644 index 000000000..3ead089e8 --- /dev/null +++ b/docs/transport/sqs.md @@ -0,0 +1,163 @@ +--- +layout: default +title: Amazon SQS +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Amazon SQS transport + +A transport for [Amazon SQS](https://aws.amazon.com/sqs/) broker. +It uses internally official [aws sdk library](https://packagist.org/packages/aws/aws-sdk-php) + +* [Installation](#installation) +* [Create context](#create-context) +* [Declare queue](#decalre-queue) +* [Send message to queue](#send-message-to-queue) +* [Send delay message](#send-delay-message) +* [Consume message](#consume-message) +* [Purge queue messages](#purge-queue-messages) +* [Queue from another AWS account](#queue-from-another-aws-account) + +## Installation + +```bash +$ composer require enqueue/sqs +``` + +## Create context + +```php + 'aKey', + 'secret' => 'aSecret', + 'region' => 'aRegion', +]); + +// same as above but given as DSN string. You may need to url encode secret if it contains special char (like +) +$factory = new SqsConnectionFactory('sqs:?key=aKey&secret=aSecret®ion=aRegion'); + +$context = $factory->createContext(); + +// using a pre-configured client +$client = new Aws\Sqs\SqsClient([ /* ... */ ]); +$factory = new SqsConnectionFactory($client); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('sqs:')->createContext(); +``` + +## Declare queue. + +Declare queue operation creates a queue on a broker side. + +```php +createQueue('foo'); +$context->declareQueue($fooQueue); + +// to remove queue use deleteQueue method +//$context->deleteQueue($fooQueue); +``` + +## Send message to queue + +```php +createQueue('foo'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Send delay message + +```php +createQueue('foo'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer() + ->setDeliveryDelay(60000) // 60 sec + + ->send($fooQueue, $message) +; +``` + +## Consume message: + +```php +createQueue('foo'); +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Purge queue messages: + +```php +createQueue('foo'); + +$context->purgeQueue($fooQueue); +``` + +## Queue from another AWS account + +SQS allows to use queues from another account. You could set it globally for all queues via option `queue_owner_aws_account_id` or +per queue using `SqsDestination::setQueueOwnerAWSAccountId` method. + +```php +createContext(); + +// per queue. +$queue = $context->createQueue('foo'); +$queue->setQueueOwnerAWSAccountId('awsAccountId'); +``` + +## Multi region examples + +Enqueue SQS provides a generic multi-region support. This enables users to specify which AWS Region to send a command to by setting region on SqsDestination. +You might need it to access SQS FIFO queue because they are not available for all regions. +If not specified the default region is used. + +```php +createContext(); + +$queue = $context->createQueue('foo'); +$queue->setRegion('us-west-2'); + +// the request goes to US West (Oregon) Region +$context->declareQueue($queue); +``` + +[back to index](../index.md) diff --git a/docs/transport/stomp.md b/docs/transport/stomp.md new file mode 100644 index 000000000..053765d67 --- /dev/null +++ b/docs/transport/stomp.md @@ -0,0 +1,104 @@ +--- +layout: default +title: STOMP +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# STOMP transport + +* [Installation](#installation) +* [Create context](#create-context) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Consume message](#consume-message) + +## Installation + +```bash +$ composer require enqueue/stomp +``` + +## Create context + +```php + 'example.com', + 'port' => 1000, + 'login' => 'theLogin', +]); + +// same as above but given as DSN string +$factory = new StompConnectionFactory('stomp://example.com:1000?login=theLogin'); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('stomp:')->createContext(); +``` + +## Send message to topic + +```php +createMessage('Hello world!'); + +$fooTopic = $context->createTopic('foo'); + +$context->createProducer()->send($fooTopic, $message); +``` + +## Send message to queue + +```php +createMessage('Hello world!'); + +$fooQueue = $context->createQueue('foo'); + +$context->createProducer()->send($fooQueue, $message); +``` + +## Consume message: + +```php +createQueue('foo'); + +$consumer = $context->createConsumer($fooQueue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +[back to index](index.md) diff --git a/docs/transport/wamp.md b/docs/transport/wamp.md new file mode 100644 index 000000000..9187fc5e8 --- /dev/null +++ b/docs/transport/wamp.md @@ -0,0 +1,116 @@ +--- +layout: default +title: WAMP +parent: Transports +nav_order: 3 +--- +{% include support.md %} + +# Web Application Messaging Protocol (WAMP) Transport + +A transport for [Web Application Messaging Protocol](https://wamp-proto.org/). +WAMP is an open standard WebSocket subprotocol. +It uses internally Thruway PHP library [thruway/client](https://github.com/thruway/client) + +* [Installation](#installation) +* [Start the WAMP router](#start-the-wamp-router) +* [Create context](#create-context) +* [Consume message](#consume-message) +* [Subscription consumer](#subscription-consumer) +* [Send message to topic](#send-message-to-topic) + +## Installation + +```bash +$ composer require enqueue/wamp +``` + +## Start the WAMP router + +You can get a WAMP router with [Thruway](https://github.com/voryx/Thruway): + +```bash +$ composer require voryx/thruway +$ php vendor/voryx/thruway/Examples/SimpleWsRouter.php +``` + +Thruway is now running on 127.0.0.1 port 9090 + + +## Create context + +```php +createContext(); +``` + +## Consume message: + +Start message consumer before send message to the topic + +```php +createTopic('foo'); + +$consumer = $context->createConsumer($fooQueue); + +while (true) { + if ($message = $consumer->receive()) { + // process a message + } +} +``` + +## Subscription consumer + +```php +createConsumer($fooQueue); +$barConsumer = $context->createConsumer($barQueue); + +$subscriptionConsumer = $context->createSubscriptionConsumer(); +$subscriptionConsumer->subscribe($fooConsumer, function(Message $message, Consumer $consumer) { + // process message + + return true; +}); +$subscriptionConsumer->subscribe($barConsumer, function(Message $message, Consumer $consumer) { + // process message + + return true; +}); + +$subscriptionConsumer->consume(2000); // 2 sec +``` + +## Send message to topic + +```php +createTopic('foo'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooTopic, $message); +``` + +[back to index](../index.md) diff --git a/docs/yii/amqp_driver.md b/docs/yii/amqp_driver.md new file mode 100644 index 000000000..49798b331 --- /dev/null +++ b/docs/yii/amqp_driver.md @@ -0,0 +1,79 @@ +--- +layout: default +parent: Yii +title: AMQP Interop driver +nav_order: 1 +--- +{% include support.md %} + +# Yii2Queue. AMQP Interop driver + +_**Note:** This a copy of [AMQP Interop doc](https://github.com/yiisoft/yii2-queue/blob/master/docs/guide/driver-amqp-interop.md) from the yiisoft/yii2-queue [repository](https://github.com/yiisoft/yii2-queue)._ + + +The driver works with RabbitMQ queues. + +In order for it to work you should add any [amqp interop](https://github.com/queue-interop/queue-interop#amqp-interop) compatible transport to your project, for example `enqueue/amqp-lib` package. + +Advantages: + +* It would work with any amqp interop compatible transports, such as + + * [enqueue/amqp-ext](https://github.com/php-enqueue/amqp-ext) based on [PHP amqp extension](https://github.com/pdezwart/php-amqp) + * [enqueue/amqp-lib](https://github.com/php-enqueue/amqp-lib) based on [php-amqplib/php-amqplib](https://github.com/php-amqplib/php-amqplib) + * [enqueue/amqp-bunny](https://github.com/php-enqueue/amqp-bunny) based on [bunny](https://github.com/jakubkulhan/bunny) + +* Supports priorities +* Supports delays +* Supports ttr +* Supports attempts +* Contains new options like: vhost, connection_timeout, qos_prefetch_count and so on. +* Supports Secure (SSL) AMQP connections. +* An ability to set DSN like: amqp:, amqps: or amqp://user:pass@localhost:1000/vhost + +Configuration example: + +```php +return [ + 'bootstrap' => [ + 'queue', // The component registers own console commands + ], + 'components' => [ + 'queue' => [ + 'class' => \yii\queue\amqp_interop\Queue::class, + 'port' => 5672, + 'user' => 'guest', + 'password' => 'guest', + 'queueName' => 'queue', + 'driver' => yii\queue\amqp_interop\Queue::ENQUEUE_AMQP_LIB, + + // or + 'dsn' => 'amqp://guest:guest@localhost:5672/%2F', + + // or, same as above + 'dsn' => 'amqp:', + ], + ], +]; +``` + +Console +------- + +Console is used to listen and process queued tasks. + +```sh +yii queue/listen +``` + +`listen` command launches a daemon which infinitely queries the queue. If there are new tasks +they're immediately obtained and executed. This method is most efficient when command is properly +daemonized via supervisor. + +`listen` command has options: + +- `--verbose`, `-v`: print executing statuses into console. +- `--isolate`: verbose mode of a job execute. If enabled, execute result of each job will be printed. +- `--color`: highlighting for verbose mode. + +[back to index](../index.md) diff --git a/docs/yii/index.md b/docs/yii/index.md new file mode 100644 index 000000000..943059db5 --- /dev/null +++ b/docs/yii/index.md @@ -0,0 +1,9 @@ +--- +layout: default +title: Yii +has_children: true +nav_order: 9 +permalink: /yii +--- + +{:toc} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..b689b6b8a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,16 @@ +parameters: + excludePaths: + - docs + - bin + - docker + - var + - pkg/amqp-lib/tutorial + - pkg/enqueue-bundle/Tests/Functional/App/AsyncListener.php + - pkg/enqueue/Util/UUID.php + - pkg/job-queue/Test/JobRunner.php + - pkg/job-queue/Tests/Functional/app/AppKernel.php + - pkg/rdkafka/RdKafkaConsumer.php + - pkg/async-event-dispatcher/DependencyInjection/Configuration.php + - pkg/enqueue-bundle/DependencyInjection/Configuration.php + - pkg/enqueue/Tests/Symfony/DependencyInjection/TransportFactoryTest.php + - pkg/simple-client/SimpleClient.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 21c4f77fe..f5ba01d8f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,34 +1,93 @@ - + - - pkg/psr-queue/Tests - - pkg/enqueue/Tests - + pkg/stomp/Tests - + pkg/amqp-ext/Tests + + pkg/amqp-lib/Tests + + + + pkg/amqp-bunny/Tests + + + + pkg/amqp-lib/Tests + + + + pkg/amqp-tools/Tests + + + + pkg/fs/Tests + + + + pkg/redis/Tests + + + + pkg/dbal/Tests + + + + pkg/null/Tests + + + + pkg/sqs/Tests + + + + pkg/sns/Tests + + + + pkg/pheanstalk/Tests + + + + pkg/gearman/Tests + + + + pkg/rdkafka/Tests + + + + pkg/gps/Tests + + + + pkg/mongodb/Tests + + pkg/enqueue-bundle/Tests @@ -36,14 +95,46 @@ pkg/job-queue/Tests + + + pkg/simple-client/Tests + + + + pkg/async-event-dispatcher/Tests + + + + pkg/async-command/Tests + + + + pkg/dsn/Tests + + + + pkg/wamp/Tests + + + + pkg/monitoring/Tests + + + + pkg/snsqs/Tests + - - + + + + + + . - - ./vendor - - - + + + ./vendor + + diff --git a/pkg/amqp-bunny/.gitattributes b/pkg/amqp-bunny/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/amqp-bunny/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/amqp-bunny/.github/workflows/ci.yml b/pkg/amqp-bunny/.github/workflows/ci.yml new file mode 100644 index 000000000..5448d7b1a --- /dev/null +++ b/pkg/amqp-bunny/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/psr-queue/.gitignore b/pkg/amqp-bunny/.gitignore similarity index 100% rename from pkg/psr-queue/.gitignore rename to pkg/amqp-bunny/.gitignore diff --git a/pkg/amqp-bunny/AmqpConnectionFactory.php b/pkg/amqp-bunny/AmqpConnectionFactory.php new file mode 100644 index 000000000..749e63be0 --- /dev/null +++ b/pkg/amqp-bunny/AmqpConnectionFactory.php @@ -0,0 +1,111 @@ +config = (new ConnectionConfig($config)) + ->addSupportedScheme('amqp+bunny') + ->addDefaultOption('tcp_nodelay', null) + ->parse() + ; + + if (in_array('rabbitmq', $this->config->getSchemeExtensions(), true)) { + $this->setDelayStrategy(new RabbitMqDlxDelayStrategy()); + } + } + + /** + * @return AmqpContext + */ + public function createContext(): Context + { + if ($this->config->isLazy()) { + $context = new AmqpContext(function () { + $channel = $this->establishConnection()->channel(); + $channel->qos($this->config->getQosPrefetchSize(), $this->config->getQosPrefetchCount(), $this->config->isQosGlobal()); + + return $channel; + }, $this->config->getConfig()); + $context->setDelayStrategy($this->delayStrategy); + + return $context; + } + + $context = new AmqpContext($this->establishConnection()->channel(), $this->config->getConfig()); + $context->setDelayStrategy($this->delayStrategy); + $context->setQos($this->config->getQosPrefetchSize(), $this->config->getQosPrefetchCount(), $this->config->isQosGlobal()); + + return $context; + } + + public function getConfig(): ConnectionConfig + { + return $this->config; + } + + private function establishConnection(): BunnyClient + { + if ($this->config->isSslOn()) { + throw new \LogicException('The bunny library does not support SSL connections'); + } + + if (false == $this->client) { + $bunnyConfig = []; + $bunnyConfig['host'] = $this->config->getHost(); + $bunnyConfig['port'] = $this->config->getPort(); + $bunnyConfig['vhost'] = $this->config->getVHost(); + $bunnyConfig['user'] = $this->config->getUser(); + $bunnyConfig['password'] = $this->config->getPass(); + $bunnyConfig['read_write_timeout'] = min($this->config->getReadTimeout(), $this->config->getWriteTimeout()); + $bunnyConfig['timeout'] = $this->config->getConnectionTimeout(); + + // @see https://github.com/php-enqueue/enqueue-dev/issues/229 + // $bunnyConfig['persistent'] = $this->config->isPersisted(); + // if ($this->config->isPersisted()) { + // $bunnyConfig['path'] = 'enqueue';//$this->config->getOption('path', $this->config->getOption('vhost')); + // } + + if ($this->config->getHeartbeat()) { + $bunnyConfig['heartbeat'] = $this->config->getHeartbeat(); + } + + if (null !== $this->config->getOption('tcp_nodelay')) { + $bunnyConfig['tcp_nodelay'] = $this->config->getOption('tcp_nodelay'); + } + + $this->client = new BunnyClient($bunnyConfig); + $this->client->connect(); + } + + return $this->client; + } +} diff --git a/pkg/amqp-bunny/AmqpConsumer.php b/pkg/amqp-bunny/AmqpConsumer.php new file mode 100644 index 000000000..89301c80c --- /dev/null +++ b/pkg/amqp-bunny/AmqpConsumer.php @@ -0,0 +1,140 @@ +context = $context; + $this->channel = $context->getBunnyChannel(); + $this->queue = $queue; + $this->flags = self::FLAG_NOPARAM; + } + + public function setConsumerTag(?string $consumerTag = null): void + { + $this->consumerTag = $consumerTag; + } + + public function getConsumerTag(): ?string + { + return $this->consumerTag; + } + + public function clearFlags(): void + { + $this->flags = self::FLAG_NOPARAM; + } + + public function addFlag(int $flag): void + { + $this->flags |= $flag; + } + + public function getFlags(): int + { + return $this->flags; + } + + public function setFlags(int $flags): void + { + $this->flags = $flags; + } + + /** + * @return InteropAmqpQueue + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return InteropAmqpMessage + */ + public function receive(int $timeout = 0): ?Message + { + $end = microtime(true) + ($timeout / 1000); + + while (0 === $timeout || microtime(true) < $end) { + if ($message = $this->receiveNoWait()) { + return $message; + } + + usleep(100000); // 100ms + } + + return null; + } + + /** + * @return InteropAmqpMessage + */ + public function receiveNoWait(): ?Message + { + if ($message = $this->channel->get($this->queue->getQueueName(), (bool) ($this->getFlags() & InteropAmqpConsumer::FLAG_NOACK))) { + return $this->context->convertMessage($message); + } + + return null; + } + + /** + * @param InteropAmqpMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + $bunnyMessage = new BunnyMessage('', $message->getDeliveryTag(), '', '', '', [], ''); + $this->channel->ack($bunnyMessage); + } + + /** + * @param InteropAmqpMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + $bunnyMessage = new BunnyMessage('', $message->getDeliveryTag(), '', '', '', [], ''); + $this->channel->reject($bunnyMessage, $requeue); + } +} diff --git a/pkg/amqp-bunny/AmqpContext.php b/pkg/amqp-bunny/AmqpContext.php new file mode 100644 index 000000000..151cbd842 --- /dev/null +++ b/pkg/amqp-bunny/AmqpContext.php @@ -0,0 +1,435 @@ +config = array_replace([ + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + ], $config); + + if ($bunnyChannel instanceof Channel) { + $this->bunnyChannel = $bunnyChannel; + } elseif (is_callable($bunnyChannel)) { + $this->bunnyChannelFactory = $bunnyChannel; + } else { + throw new \InvalidArgumentException('The bunnyChannel argument must be either \Bunny\Channel or callable that return it.'); + } + } + + /** + * @return InteropAmqpMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new AmqpMessage($body, $properties, $headers); + } + + /** + * @return InteropAmqpQueue + */ + public function createQueue(string $name): Queue + { + return new AmqpQueue($name); + } + + /** + * @return InteropAmqpTopic + */ + public function createTopic(string $name): Topic + { + return new AmqpTopic($name); + } + + /** + * @param AmqpDestination $destination + * + * @return AmqpConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + $destination instanceof Topic + ? InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpTopic::class) + : InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpQueue::class) + ; + + if ($destination instanceof AmqpTopic) { + $queue = $this->createTemporaryQueue(); + $this->bind(new AmqpBind($destination, $queue, $queue->getQueueName())); + + return new AmqpConsumer($this, $queue); + } + + return new AmqpConsumer($this, $destination); + } + + /** + * @return AmqpSubscriptionConsumer + */ + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new AmqpSubscriptionConsumer($this); + } + + /** + * @return AmqpProducer + */ + public function createProducer(): Producer + { + $producer = new AmqpProducer($this->getBunnyChannel(), $this); + $producer->setDelayStrategy($this->delayStrategy); + + return $producer; + } + + /** + * @return InteropAmqpQueue + */ + public function createTemporaryQueue(): Queue + { + $frame = $this->getBunnyChannel()->queueDeclare('', false, false, true, false); + + $queue = $this->createQueue($frame->queue); + $queue->addFlag(InteropAmqpQueue::FLAG_EXCLUSIVE); + + return $queue; + } + + public function declareTopic(InteropAmqpTopic $topic): void + { + $this->getBunnyChannel()->exchangeDeclare( + $topic->getTopicName(), + $topic->getType(), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_PASSIVE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_DURABLE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_AUTODELETE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_INTERNAL), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_NOWAIT), + $topic->getArguments() + ); + } + + public function deleteTopic(InteropAmqpTopic $topic): void + { + $this->getBunnyChannel()->exchangeDelete( + $topic->getTopicName(), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_IFUNUSED), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_NOWAIT) + ); + } + + public function declareQueue(InteropAmqpQueue $queue): int + { + $frame = $this->getBunnyChannel()->queueDeclare( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_PASSIVE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_DURABLE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_EXCLUSIVE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_AUTODELETE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT), + $queue->getArguments() + ); + + return $frame->messageCount; + } + + public function deleteQueue(InteropAmqpQueue $queue): void + { + $this->getBunnyChannel()->queueDelete( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_IFUNUSED), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_IFEMPTY), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT) + ); + } + + /** + * @param InteropAmqpQueue $queue + */ + public function purgeQueue(Queue $queue): void + { + $this->getBunnyChannel()->queuePurge( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT) + ); + } + + public function bind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to bind queue to queue. It is possible to bind topic to queue or topic to topic'); + } + + // bind exchange to exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $this->getBunnyChannel()->exchangeBind( + $bind->getTarget()->getTopicName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + // bind queue to exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $this->getBunnyChannel()->queueBind( + $bind->getSource()->getQueueName(), + $bind->getTarget()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + // bind exchange to queue + } else { + $this->getBunnyChannel()->queueBind( + $bind->getTarget()->getQueueName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + } + } + + public function unbind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to bind queue to queue. It is possible to bind topic to queue or topic to topic'); + } + + // bind exchange to exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $this->getBunnyChannel()->exchangeUnbind( + $bind->getTarget()->getTopicName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + // bind queue to exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $this->getBunnyChannel()->queueUnbind( + $bind->getSource()->getQueueName(), + $bind->getTarget()->getTopicName(), + $bind->getRoutingKey(), + $bind->getArguments() + ); + // bind exchange to queue + } else { + $this->getBunnyChannel()->queueUnbind( + $bind->getTarget()->getQueueName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + $bind->getArguments() + ); + } + } + + public function close(): void + { + if ($this->bunnyChannel) { + $this->bunnyChannel->close(); + } + } + + public function setQos(int $prefetchSize, int $prefetchCount, bool $global): void + { + $this->getBunnyChannel()->qos($prefetchSize, $prefetchCount, $global); + } + + public function getBunnyChannel(): Channel + { + if (false == $this->bunnyChannel) { + $bunnyChannel = call_user_func($this->bunnyChannelFactory); + if (false == $bunnyChannel instanceof Channel) { + throw new \LogicException(sprintf('The factory must return instance of \Bunny\Channel. It returned %s', is_object($bunnyChannel) ? $bunnyChannel::class : gettype($bunnyChannel))); + } + + $this->bunnyChannel = $bunnyChannel; + } + + return $this->bunnyChannel; + } + + /** + * @internal It must be used here and in the consumer only + */ + public function convertMessage(BunnyMessage $bunnyMessage): InteropAmqpMessage + { + $headers = $bunnyMessage->headers; + $headers = $this->convertHeadersFromBunnyNotation($headers); + + $properties = []; + if (isset($headers['application_headers'])) { + $properties = $headers['application_headers']; + } + unset($headers['application_headers']); + + if (array_key_exists('timestamp', $headers) && $headers['timestamp']) { + /** @var \DateTime $date */ + $date = $headers['timestamp']; + + $headers['timestamp'] = (int) $date->format('U'); + } + + $message = new AmqpMessage($bunnyMessage->content, $properties, $headers); + $message->setDeliveryTag((int) $bunnyMessage->deliveryTag); + $message->setRedelivered($bunnyMessage->redelivered); + $message->setRoutingKey($bunnyMessage->routingKey); + + return $message; + } + + /** @internal It must be used here and in the producer only */ + public function convertHeadersToBunnyNotation(array $headers): array + { + if (isset($headers['content_type'])) { + $headers['content-type'] = $headers['content_type']; + unset($headers['content_type']); + } + + if (isset($headers['content_encoding'])) { + $headers['content-encoding'] = $headers['content_encoding']; + unset($headers['content_encoding']); + } + + if (isset($headers['delivery_mode'])) { + $headers['delivery-mode'] = $headers['delivery_mode']; + unset($headers['delivery_mode']); + } + + if (isset($headers['correlation_id'])) { + $headers['correlation-id'] = $headers['correlation_id']; + unset($headers['correlation_id']); + } + + if (isset($headers['reply_to'])) { + $headers['reply-to'] = $headers['reply_to']; + unset($headers['reply_to']); + } + + if (isset($headers['message_id'])) { + $headers['message-id'] = $headers['message_id']; + unset($headers['message_id']); + } + + if (isset($headers['user_id'])) { + $headers['user-id'] = $headers['user_id']; + unset($headers['user_id']); + } + + if (isset($headers['app_id'])) { + $headers['app-id'] = $headers['app_id']; + unset($headers['app_id']); + } + + if (isset($headers['cluster_id'])) { + $headers['cluster-id'] = $headers['cluster_id']; + unset($headers['cluster_id']); + } + + return $headers; + } + + /** @internal It must be used here and in the consumer only */ + public function convertHeadersFromBunnyNotation(array $bunnyHeaders): array + { + if (isset($bunnyHeaders['content-type'])) { + $bunnyHeaders['content_type'] = $bunnyHeaders['content-type']; + unset($bunnyHeaders['content-type']); + } + + if (isset($bunnyHeaders['content-encoding'])) { + $bunnyHeaders['content_encoding'] = $bunnyHeaders['content-encoding']; + unset($bunnyHeaders['content-encoding']); + } + + if (isset($bunnyHeaders['delivery-mode'])) { + $bunnyHeaders['delivery_mode'] = $bunnyHeaders['delivery-mode']; + unset($bunnyHeaders['delivery-mode']); + } + + if (isset($bunnyHeaders['correlation-id'])) { + $bunnyHeaders['correlation_id'] = $bunnyHeaders['correlation-id']; + unset($bunnyHeaders['correlation-id']); + } + + if (isset($bunnyHeaders['reply-to'])) { + $bunnyHeaders['reply_to'] = $bunnyHeaders['reply-to']; + unset($bunnyHeaders['reply-to']); + } + + if (isset($bunnyHeaders['message-id'])) { + $bunnyHeaders['message_id'] = $bunnyHeaders['message-id']; + unset($bunnyHeaders['message-id']); + } + + if (isset($bunnyHeaders['user-id'])) { + $bunnyHeaders['user_id'] = $bunnyHeaders['user-id']; + unset($bunnyHeaders['user-id']); + } + + if (isset($bunnyHeaders['app-id'])) { + $bunnyHeaders['app_id'] = $bunnyHeaders['app-id']; + unset($bunnyHeaders['app-id']); + } + + if (isset($bunnyHeaders['cluster-id'])) { + $bunnyHeaders['cluster_id'] = $bunnyHeaders['cluster-id']; + unset($bunnyHeaders['cluster-id']); + } + + return $bunnyHeaders; + } +} diff --git a/pkg/amqp-bunny/AmqpProducer.php b/pkg/amqp-bunny/AmqpProducer.php new file mode 100644 index 000000000..178ff81a8 --- /dev/null +++ b/pkg/amqp-bunny/AmqpProducer.php @@ -0,0 +1,171 @@ +channel = $channel; + $this->context = $context; + } + + /** + * @param InteropAmqpTopic|InteropAmqpQueue $destination + * @param InteropAmqpMessage $message + */ + public function send(Destination $destination, Message $message): void + { + $destination instanceof Topic + ? InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpTopic::class) + : InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpQueue::class) + ; + + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + try { + $this->doSend($destination, $message); + } catch (\Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @return self + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $this->delayStrategy) { + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return self + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return self + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } + + private function doSend(InteropAmqpDestination $destination, InteropAmqpMessage $message): void + { + if (null !== $this->priority && null === $message->getPriority()) { + $message->setPriority($this->priority); + } + + if (null !== $this->timeToLive && null === $message->getExpiration()) { + $message->setExpiration($this->timeToLive); + } + + $amqpProperties = $message->getHeaders(); + $amqpProperties = $this->context->convertHeadersToBunnyNotation($amqpProperties); + + if (array_key_exists('timestamp', $amqpProperties) && null !== $amqpProperties['timestamp']) { + $amqpProperties['timestamp'] = \DateTime::createFromFormat('U', (string) $amqpProperties['timestamp']); + } + + if ($appProperties = $message->getProperties()) { + $amqpProperties['application_headers'] = $appProperties; + } + + if ($this->deliveryDelay) { + $this->delayStrategy->delayMessage($this->context, $destination, $message, $this->deliveryDelay); + } elseif ($destination instanceof InteropAmqpTopic) { + $this->channel->publish( + $message->getBody(), + $amqpProperties, + $destination->getTopicName(), + $message->getRoutingKey(), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_MANDATORY), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_IMMEDIATE) + ); + } else { + $this->channel->publish( + $message->getBody(), + $amqpProperties, + '', + $destination->getQueueName(), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_MANDATORY), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_IMMEDIATE) + ); + } + } +} diff --git a/pkg/amqp-bunny/AmqpSubscriptionConsumer.php b/pkg/amqp-bunny/AmqpSubscriptionConsumer.php new file mode 100644 index 000000000..2904c1c19 --- /dev/null +++ b/pkg/amqp-bunny/AmqpSubscriptionConsumer.php @@ -0,0 +1,133 @@ +context = $context; + + $this->subscribers = []; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('There is no subscribers. Consider calling basicConsumeSubscribe before consuming'); + } + + $signalHandler = new SignalSocketHelper(); + $signalHandler->beforeSocket(); + + try { + $this->context->getBunnyChannel()->getClient()->run(0 !== $timeout ? $timeout / 1000 : null); + } catch (ClientException $e) { + if (str_starts_with($e->getMessage(), 'stream_select() failed') && $signalHandler->wasThereSignal()) { + return; + } + + throw $e; + } finally { + $signalHandler->afterSocket(); + } + } + + /** + * @param AmqpConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if ($consumer->getConsumerTag() && array_key_exists($consumer->getConsumerTag(), $this->subscribers)) { + return; + } + + $bunnyCallback = function (Message $message, Channel $channel, Client $bunny) { + $receivedMessage = $this->context->convertMessage($message); + $receivedMessage->setConsumerTag($message->consumerTag); + + /** + * @var AmqpConsumer + * @var callable $callback + */ + list($consumer, $callback) = $this->subscribers[$message->consumerTag]; + + if (false === call_user_func($callback, $receivedMessage, $consumer)) { + $bunny->stop(); + } + }; + + $frame = $this->context->getBunnyChannel()->consume( + $bunnyCallback, + $consumer->getQueue()->getQueueName(), + $consumer->getConsumerTag() ?? '', + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOLOCAL), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOACK), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_EXCLUSIVE), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOWAIT) + ); + + if (empty($frame->consumerTag)) { + throw new Exception('Got empty consumer tag'); + } + + $consumer->setConsumerTag($frame->consumerTag); + + $this->subscribers[$frame->consumerTag] = [$consumer, $callback]; + } + + /** + * @param AmqpConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if (false == $consumer->getConsumerTag()) { + return; + } + + $consumerTag = $consumer->getConsumerTag(); + + $this->context->getBunnyChannel()->cancel($consumerTag); + $consumer->setConsumerTag(null); + unset($this->subscribers[$consumerTag]); + } + + public function unsubscribeAll(): void + { + foreach ($this->subscribers as list($consumer)) { + $this->unsubscribe($consumer); + } + } +} diff --git a/pkg/amqp-bunny/BunnyClient.php b/pkg/amqp-bunny/BunnyClient.php new file mode 100644 index 000000000..19a76013a --- /dev/null +++ b/pkg/amqp-bunny/BunnyClient.php @@ -0,0 +1,22 @@ +getMessage()) { + throw $e; + } + } + } +} diff --git a/pkg/amqp-bunny/LICENSE b/pkg/amqp-bunny/LICENSE new file mode 100644 index 000000000..d9736f8bf --- /dev/null +++ b/pkg/amqp-bunny/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/amqp-bunny/README.md b/pkg/amqp-bunny/README.md new file mode 100644 index 000000000..09f4005fe --- /dev/null +++ b/pkg/amqp-bunny/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# AMQP Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-bunny/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-bunny/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-bunny/d/total.png)](https://packagist.org/packages/enqueue/amqp-bunny) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-bunny/version.png)](https://packagist.org/packages/enqueue/amqp-bunny) + +This is an implementation of [amqp interop](https://github.com/queue-interop/amqp-interop). It uses [bunny](https://github.com/jakubkulhan/bunny) internally. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/amqp_bunny/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/amqp-bunny/Tests/AmqpConnectionFactoryTest.php b/pkg/amqp-bunny/Tests/AmqpConnectionFactoryTest.php new file mode 100644 index 000000000..da54dfc0d --- /dev/null +++ b/pkg/amqp-bunny/Tests/AmqpConnectionFactoryTest.php @@ -0,0 +1,28 @@ +assertClassImplements(ConnectionFactory::class, AmqpConnectionFactory::class); + } + + public function testShouldSetRabbitMqDlxDelayStrategyIfRabbitMqSchemeExtensionPresent() + { + $factory = new AmqpConnectionFactory('amqp+rabbitmq:'); + + $this->assertAttributeInstanceOf(RabbitMqDlxDelayStrategy::class, 'delayStrategy', $factory); + } +} diff --git a/pkg/amqp-bunny/Tests/AmqpConsumerTest.php b/pkg/amqp-bunny/Tests/AmqpConsumerTest.php new file mode 100644 index 000000000..c2c694566 --- /dev/null +++ b/pkg/amqp-bunny/Tests/AmqpConsumerTest.php @@ -0,0 +1,226 @@ +assertClassImplements(Consumer::class, AmqpConsumer::class); + } + + public function testShouldReturnQueue() + { + $queue = new AmqpQueue('aName'); + + $consumer = new AmqpConsumer($this->createContextMock(), $queue); + + $this->assertSame($queue, $consumer->getQueue()); + } + + public function testOnAcknowledgeShouldThrowExceptionIfNotAmqpMessage() + { + $consumer = new AmqpConsumer($this->createContextMock(), new AmqpQueue('aName')); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but'); + + $consumer->acknowledge(new NullMessage()); + } + + public function testOnRejectShouldThrowExceptionIfNotAmqpMessage() + { + $consumer = new AmqpConsumer($this->createContextMock(), new AmqpQueue('aName')); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but'); + + $consumer->reject(new NullMessage()); + } + + public function testOnAcknowledgeShouldAcknowledgeMessage() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('ack') + ->with($this->isInstanceOf(Message::class)) + ->willReturnCallback(function (Message $message) { + $this->assertSame(145, $message->deliveryTag); + }); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getBunnyChannel') + ->willReturn($channel) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $message = new AmqpMessage(); + $message->setDeliveryTag(145); + + $consumer->acknowledge($message); + } + + public function testOnRejectShouldRejectMessage() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('reject') + ->with($this->isInstanceOf(Message::class), false) + ->willReturnCallback(function (Message $message) { + $this->assertSame(167, $message->deliveryTag); + }); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getBunnyChannel') + ->willReturn($channel) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $message = new AmqpMessage(); + $message->setDeliveryTag(167); + + $consumer->reject($message, false); + } + + public function testOnRejectShouldRequeueMessage() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('reject') + ->with($this->isInstanceOf(Message::class), true) + ->willReturnCallback(function (Message $message) { + $this->assertSame(178, $message->deliveryTag); + }); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getBunnyChannel') + ->willReturn($channel) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $message = new AmqpMessage(); + $message->setDeliveryTag(178); + + $consumer->reject($message, true); + } + + public function testShouldReturnMessageOnReceiveNoWait() + { + $bunnyMessage = new Message('', 'delivery-tag', true, '', '', [], 'body'); + + $message = new AmqpMessage(); + + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('get') + ->willReturn($bunnyMessage) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getBunnyChannel') + ->willReturn($channel) + ; + $context + ->expects($this->once()) + ->method('convertMessage') + ->with($this->identicalTo($bunnyMessage)) + ->willReturn($message) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $receivedMessage = $consumer->receiveNoWait(); + + $this->assertSame($message, $receivedMessage); + } + + public function testShouldReturnMessageOnReceiveWithReceiveMethodBasicGet() + { + $bunnyMessage = new Message('', 'delivery-tag', true, '', '', [], 'body'); + + $message = new AmqpMessage(); + + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('get') + ->willReturn($bunnyMessage) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getBunnyChannel') + ->willReturn($channel) + ; + $context + ->expects($this->once()) + ->method('convertMessage') + ->with($this->identicalTo($bunnyMessage)) + ->willReturn($message) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $receivedMessage = $consumer->receive(); + + $this->assertSame($message, $receivedMessage); + } + + /** + * @return MockObject|Client + */ + public function createClientMock() + { + return $this->createMock(Client::class); + } + + /** + * @return MockObject|AmqpContext + */ + public function createContextMock() + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return MockObject|Channel + */ + public function createBunnyChannelMock() + { + return $this->createMock(Channel::class); + } +} diff --git a/pkg/amqp-bunny/Tests/AmqpContextTest.php b/pkg/amqp-bunny/Tests/AmqpContextTest.php new file mode 100644 index 000000000..69d9b5012 --- /dev/null +++ b/pkg/amqp-bunny/Tests/AmqpContextTest.php @@ -0,0 +1,254 @@ +createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchangeDeclare') + ->with( + $this->identicalTo('name'), + $this->identicalTo('type'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->identicalTo(['key' => 'value']) + ) + ; + + $topic = new AmqpTopic('name'); + $topic->setType('type'); + $topic->setArguments(['key' => 'value']); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $topic->addFlag(AmqpTopic::FLAG_NOWAIT); + $topic->addFlag(AmqpTopic::FLAG_PASSIVE); + $topic->addFlag(AmqpTopic::FLAG_INTERNAL); + $topic->addFlag(AmqpTopic::FLAG_AUTODELETE); + + $session = new AmqpContext($channel, []); + $session->declareTopic($topic); + } + + public function testShouldDeleteTopic() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchangeDelete') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue() + ) + ; + + $topic = new AmqpTopic('name'); + $topic->setType('type'); + $topic->setArguments(['key' => 'value']); + $topic->addFlag(AmqpTopic::FLAG_IFUNUSED); + $topic->addFlag(AmqpTopic::FLAG_NOWAIT); + + $session = new AmqpContext($channel, []); + $session->deleteTopic($topic); + } + + public function testShouldDeclareQueue() + { + $frame = new MethodQueueDeclareOkFrame(); + $frame->queue = 'name'; + $frame->messageCount = 123; + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queueDeclare') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->identicalTo(['key' => 'value']) + ) + ->willReturn($frame) + ; + + $queue = new AmqpQueue('name'); + $queue->setArguments(['key' => 'value']); + $queue->addFlag(AmqpQueue::FLAG_AUTODELETE); + $queue->addFlag(AmqpQueue::FLAG_DURABLE); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + $queue->addFlag(AmqpQueue::FLAG_PASSIVE); + $queue->addFlag(AmqpQueue::FLAG_EXCLUSIVE); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $session = new AmqpContext($channel, []); + + $this->assertSame(123, $session->declareQueue($queue)); + } + + public function testShouldDeleteQueue() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queueDelete') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue() + ) + ; + + $queue = new AmqpQueue('name'); + $queue->setArguments(['key' => 'value']); + $queue->addFlag(AmqpQueue::FLAG_IFUNUSED); + $queue->addFlag(AmqpQueue::FLAG_IFEMPTY); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $session = new AmqpContext($channel, []); + $session->deleteQueue($queue); + } + + public function testBindShouldBindTopicToTopic() + { + $source = new AmqpTopic('source'); + $target = new AmqpTopic('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchangeBind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $context = new AmqpContext($channel, []); + $context->bind(new AmqpBind($target, $source, 'routing-key', 12345)); + } + + public function testBindShouldBindTopicToQueue() + { + $source = new AmqpTopic('source'); + $target = new AmqpQueue('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->exactly(2)) + ->method('queueBind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $context = new AmqpContext($channel, []); + $context->bind(new AmqpBind($target, $source, 'routing-key', 12345)); + $context->bind(new AmqpBind($source, $target, 'routing-key', 12345)); + } + + public function testShouldUnBindTopicFromTopic() + { + $source = new AmqpTopic('source'); + $target = new AmqpTopic('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchangeUnbind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $context = new AmqpContext($channel, []); + $context->unbind(new AmqpBind($target, $source, 'routing-key', 12345)); + } + + public function testShouldUnBindTopicFromQueue() + { + $source = new AmqpTopic('source'); + $target = new AmqpQueue('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->exactly(2)) + ->method('queueUnbind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), ['key' => 'value']) + ; + + $context = new AmqpContext($channel, []); + $context->unbind(new AmqpBind($target, $source, 'routing-key', 12345, ['key' => 'value'])); + $context->unbind(new AmqpBind($source, $target, 'routing-key', 12345, ['key' => 'value'])); + } + + public function testShouldCloseChannelConnection() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('close') + ; + + $context = new AmqpContext($channel, []); + $context->createProducer(); + + $context->close(); + } + + public function testShouldPurgeQueue() + { + $queue = new AmqpQueue('queue'); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queuePurge') + ->with($this->identicalTo('queue'), $this->isTrue()) + ; + + $context = new AmqpContext($channel, []); + $context->purgeQueue($queue); + } + + public function testShouldSetQos() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('qos') + ->with($this->identicalTo(123), $this->identicalTo(456), $this->isTrue()) + ; + + $context = new AmqpContext($channel, []); + $context->setQos(123, 456, true); + } + + public function testShouldReturnExpectedSubscriptionConsumerInstance() + { + $context = new AmqpContext($this->createChannelMock(), []); + + $this->assertInstanceOf(AmqpSubscriptionConsumer::class, $context->createSubscriptionConsumer()); + } + + /** + * @return MockObject|Channel + */ + public function createChannelMock() + { + return $this->createMock(Channel::class); + } +} diff --git a/pkg/amqp-bunny/Tests/AmqpProducerTest.php b/pkg/amqp-bunny/Tests/AmqpProducerTest.php new file mode 100644 index 000000000..17b390341 --- /dev/null +++ b/pkg/amqp-bunny/Tests/AmqpProducerTest.php @@ -0,0 +1,237 @@ +assertClassImplements(Producer::class, AmqpProducer::class); + } + + public function testShouldThrowExceptionWhenDestinationTypeIsInvalid() + { + $producer = new AmqpProducer($this->createBunnyChannelMock(), $this->createContextMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpQueue but got'); + + $producer->send($this->createDestinationMock(), new AmqpMessage()); + } + + public function testShouldThrowExceptionWhenMessageTypeIsInvalid() + { + $producer = new AmqpProducer($this->createBunnyChannelMock(), $this->createContextMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but it is'); + + $producer->send(new AmqpTopic('name'), $this->createMessageMock()); + } + + public function testShouldPublishMessageToTopic() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('publish') + ->with('body', [], 'topic', 'routing-key', false, false) + ; + + $topic = new AmqpTopic('topic'); + + $message = new AmqpMessage('body'); + $message->setRoutingKey('routing-key'); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send($topic, $message); + } + + public function testShouldPublishMessageToQueue() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('publish') + ->with('body', [], '', 'queue', false, false) + ; + + $queue = new AmqpQueue('queue'); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send($queue, new AmqpMessage('body')); + } + + public function testShouldDelayMessage() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->never()) + ->method('publish') + ; + + $message = new AmqpMessage('body'); + $context = $this->createContextMock(); + $queue = new AmqpQueue('queue'); + + $delayStrategy = $this->createDelayStrategyMock(); + $delayStrategy + ->expects($this->once()) + ->method('delayMessage') + ->with($this->identicalTo($context), $this->identicalTo($queue), $this->identicalTo($message), 10000) + ; + + $producer = new AmqpProducer($channel, $context); + $producer->setDelayStrategy($delayStrategy); + $producer->setDeliveryDelay(10000); + + $producer->send($queue, $message); + } + + public function testShouldThrowExceptionOnSetDeliveryDelayWhenDeliveryStrategyIsNotSet() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->never()) + ->method('publish') + ; + + $producer = new AmqpProducer($channel, $this->createContextMock()); + + $this->expectException(DeliveryDelayNotSupportedException::class); + $this->expectExceptionMessage('The provider does not support delivery delay feature'); + + $producer->setDeliveryDelay(10000); + } + + public function testShouldSetMessageHeaders() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('publish') + ->with($this->anything(), ['misc' => 'text/plain']) + ; + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send(new AmqpTopic('name'), new AmqpMessage('body', [], ['misc' => 'text/plain'])); + } + + public function testShouldConvertStandartHeadersToBunnyFormat() + { + $channel = $this->createBunnyChannelMock(); + $expectedHeaders = [ + 'content-encoding' => 'utf8', + 'content-type' => 'text/plain', + 'message-id' => 'id', + 'correlation-id' => 'correlation', + 'reply-to' => 'reply', + 'delivery-mode' => 2, + ]; + $channel + ->expects($this->once()) + ->method('publish') + ->with($this->anything(), $expectedHeaders); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $message = new AmqpMessage('body', []); + $message->setMessageId('id'); + $message->setReplyTo('reply'); + $message->setDeliveryMode(2); + $message->setContentType('text/plain'); + $message->setContentEncoding('utf8'); + $message->setCorrelationId('correlation'); + + $producer->send(new AmqpTopic('name'), $message); + } + + public function testShouldSetMessageProperties() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('publish') + ->with($this->anything(), ['application_headers' => ['key' => 'value']]) + ; + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send(new AmqpTopic('name'), new AmqpMessage('body', ['key' => 'value'])); + } + + public function testShouldPropagateFlags() + { + $channel = $this->createBunnyChannelMock(); + $channel + ->expects($this->once()) + ->method('publish') + ->with($this->anything(), $this->anything(), $this->anything(), $this->anything(), true, true) + ; + + $message = new AmqpMessage('body'); + $message->addFlag(InteropAmqpMessage::FLAG_IMMEDIATE); + $message->addFlag(InteropAmqpMessage::FLAG_MANDATORY); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send(new AmqpTopic('name'), $message); + } + + /** + * @return MockObject|Message + */ + private function createMessageMock() + { + return $this->createMock(Message::class); + } + + /** + * @return MockObject|Destination + */ + private function createDestinationMock() + { + return $this->createMock(Destination::class); + } + + /** + * @return MockObject|Channel + */ + private function createBunnyChannelMock() + { + return $this->createMock(Channel::class); + } + + /** + * @return MockObject|AmqpContext + */ + private function createContextMock() + { + return $this->createPartialMock(AmqpContext::class, []); + } + + /** + * @return MockObject|DelayStrategy + */ + private function createDelayStrategyMock() + { + return $this->createMock(DelayStrategy::class); + } +} diff --git a/pkg/amqp-bunny/Tests/AmqpSubscriptionConsumerTest.php b/pkg/amqp-bunny/Tests/AmqpSubscriptionConsumerTest.php new file mode 100644 index 000000000..4bf08ded5 --- /dev/null +++ b/pkg/amqp-bunny/Tests/AmqpSubscriptionConsumerTest.php @@ -0,0 +1,27 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + /** + * @return AmqpContext|MockObject + */ + private function createAmqpContextMock() + { + return $this->createMock(AmqpContext::class); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpConnectionFactoryTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpConnectionFactoryTest.php new file mode 100644 index 000000000..6fd12b642 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpConnectionFactoryTest.php @@ -0,0 +1,14 @@ +createMock(Channel::class); + + return new AmqpContext($channel, []); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php new file mode 100644 index 000000000..e5c2d1302 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php @@ -0,0 +1,41 @@ +markTestIncomplete(); + } + + protected function createContext() + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + $factory->setDelayStrategy(new RabbitMqDelayPluginDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php new file mode 100644 index 000000000..b7c311cfb --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php @@ -0,0 +1,36 @@ +setDelayStrategy(new RabbitMqDlxDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..89530e2e6 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $queue->setArguments(['x-max-priority' => 10]); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..793e3fa78 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimestampAsIntegerTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimestampAsIntegerTest.php new file mode 100644 index 000000000..37ef1d0bd --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendAndReceiveTimestampAsIntegerTest.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..e3286ae9c --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..ce9fc2794 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..f1210d03a --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..cc47cf44a --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..f13ead179 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..683e0b1ca --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..4a549fcf8 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php @@ -0,0 +1,54 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The bunny library does not support SSL connections'); + parent::test(); + } + + protected function createContext() + { + $baseDir = realpath(__DIR__.'/../../../../'); + + // guard + $this->assertNotEmpty($baseDir); + + $certDir = $baseDir.'/var/rabbitmq_certificates'; + $this->assertDirectoryExists($certDir); + + $factory = new AmqpConnectionFactory([ + 'dsn' => getenv('AMQPS_DSN'), + 'ssl_verify' => false, + 'ssl_cacert' => $certDir.'/cacert.pem', + 'ssl_cert' => $certDir.'/cert.pem', + 'ssl_key' => $certDir.'/key.pem', + ]); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php new file mode 100644 index 000000000..1814b8c7c --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..5d4d8d40e --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..6cae48148 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,50 @@ +subscriptionConsumer) { + $this->subscriptionConsumer->unsubscribeAll(); + } + + parent::tearDown(); + } + + /** + * @return AmqpContext + */ + protected function createContext() + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + + $context = $factory->createContext(); + $context->setQos(0, 1, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php new file mode 100644 index 000000000..b7926e93e --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php new file mode 100644 index 000000000..ddf0ffca5 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..d9d8527d3 --- /dev/null +++ b/pkg/amqp-bunny/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-bunny/composer.json b/pkg/amqp-bunny/composer.json new file mode 100644 index 000000000..84d0f4309 --- /dev/null +++ b/pkg/amqp-bunny/composer.json @@ -0,0 +1,40 @@ +{ + "name": "enqueue/amqp-bunny", + "type": "library", + "description": "Message Queue Amqp Transport", + "keywords": ["messaging", "queue", "amqp", "bunny"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "bunny/bunny": "^0.4|^0.5", + "enqueue/amqp-tools": "^0.10" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\AmqpBunny\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/amqp-bunny/phpunit.xml.dist b/pkg/amqp-bunny/phpunit.xml.dist new file mode 100644 index 000000000..3ca071a57 --- /dev/null +++ b/pkg/amqp-bunny/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/amqp-ext/.gitattributes b/pkg/amqp-ext/.gitattributes new file mode 100644 index 000000000..3fab2dac1 --- /dev/null +++ b/pkg/amqp-ext/.gitattributes @@ -0,0 +1,6 @@ +/examples export-ignore +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/amqp-ext/.github/workflows/ci.yml b/pkg/amqp-ext/.github/workflows/ci.yml new file mode 100644 index 000000000..d48deb0af --- /dev/null +++ b/pkg/amqp-ext/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: php Tests/fix_composer_json.php + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/amqp-ext/.travis.yml b/pkg/amqp-ext/.travis.yml deleted file mode 100644 index df75087e6..000000000 --- a/pkg/amqp-ext/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - travis/build-php-amqp-ext - - cd $TRAVIS_BUILD_DIR - - composer self-update - - composer install --prefer-source - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/amqp-ext/AmqpConnectionFactory.php b/pkg/amqp-ext/AmqpConnectionFactory.php index ab46fe0f0..c3241d72a 100644 --- a/pkg/amqp-ext/AmqpConnectionFactory.php +++ b/pkg/amqp-ext/AmqpConnectionFactory.php @@ -2,12 +2,19 @@ namespace Enqueue\AmqpExt; -use Enqueue\Psr\ConnectionFactory; +use Enqueue\AmqpTools\ConnectionConfig; +use Enqueue\AmqpTools\DelayStrategyAware; +use Enqueue\AmqpTools\DelayStrategyAwareTrait; +use Enqueue\AmqpTools\RabbitMqDlxDelayStrategy; +use Interop\Amqp\AmqpConnectionFactory as InteropAmqpConnectionFactory; +use Interop\Queue\Context; -class AmqpConnectionFactory implements ConnectionFactory +class AmqpConnectionFactory implements InteropAmqpConnectionFactory, DelayStrategyAware { + use DelayStrategyAwareTrait; + /** - * @var array + * @var ConnectionConfig */ private $config; @@ -17,52 +24,93 @@ class AmqpConnectionFactory implements ConnectionFactory private $connection; /** - * $config = [ - * 'host' => amqp.host The host to connect too. Note: Max 1024 characters. - * 'port' => amqp.port Port on the host. - * 'vhost' => amqp.vhost The virtual host on the host. Note: Max 128 characters. - * 'login' => amqp.login The login name to use. Note: Max 128 characters. - * 'password' => amqp.password Password. Note: Max 128 characters. - * 'read_timeout' => Timeout in for income activity. Note: 0 or greater seconds. May be fractional. - * 'write_timeout' => Timeout in for outcome activity. Note: 0 or greater seconds. May be fractional. - * 'connect_timeout' => Connection timeout. Note: 0 or greater seconds. May be fractional. - * 'persisted' => bool - * ]. + * @see ConnectionConfig for possible config formats and values * - * @param $config + * @param array|string|null $config */ - public function __construct(array $config) + public function __construct($config = 'amqp:') { - $this->config = array_replace([ - 'host' => null, - 'port' => null, - 'vhost' => null, - 'login' => null, - 'password' => null, - 'read_timeout' => null, - 'write_timeout' => null, - 'connect_timeout' => null, - 'persisted' => false, - ], $config); + $this->config = (new ConnectionConfig($config)) + ->addSupportedScheme('amqp+ext') + ->addSupportedScheme('amqps+ext') + ->parse() + ; + + if (in_array('rabbitmq', $this->config->getSchemeExtensions(), true)) { + $this->setDelayStrategy(new RabbitMqDlxDelayStrategy()); + } } /** - * {@inheritdoc} - * * @return AmqpContext */ - public function createContext() + public function createContext(): Context + { + if ($this->config->isLazy()) { + $context = new AmqpContext(function () { + $extContext = $this->createExtContext($this->establishConnection()); + $extContext->qos($this->config->getQosPrefetchSize(), $this->config->getQosPrefetchCount()); + + return $extContext; + }); + $context->setDelayStrategy($this->delayStrategy); + + return $context; + } + + $context = new AmqpContext($this->createExtContext($this->establishConnection())); + $context->setDelayStrategy($this->delayStrategy); + $context->setQos($this->config->getQosPrefetchSize(), $this->config->getQosPrefetchCount(), $this->config->isQosGlobal()); + + return $context; + } + + public function getConfig(): ConnectionConfig + { + return $this->config; + } + + private function createExtContext(\AMQPConnection $extConnection): \AMQPChannel + { + return new \AMQPChannel($extConnection); + } + + private function establishConnection(): \AMQPConnection { if (false == $this->connection) { - $this->connection = new \AMQPConnection($this->config); + $extConfig = []; + $extConfig['host'] = $this->config->getHost(); + $extConfig['port'] = $this->config->getPort(); + $extConfig['vhost'] = $this->config->getVHost(); + $extConfig['login'] = $this->config->getUser(); + $extConfig['password'] = $this->config->getPass(); + $extConfig['read_timeout'] = $this->config->getReadTimeout(); + $extConfig['write_timeout'] = $this->config->getWriteTimeout(); + $extConfig['connect_timeout'] = $this->config->getConnectionTimeout(); + $extConfig['heartbeat'] = $this->config->getHeartbeat(); + + if ($this->config->isSslOn()) { + $extConfig['verify'] = $this->config->isSslVerify(); + $extConfig['cacert'] = $this->config->getSslCaCert(); + $extConfig['cert'] = $this->config->getSslCert(); + $extConfig['key'] = $this->config->getSslKey(); + } + + $this->connection = new \AMQPConnection($extConfig); - $this->config['persisted'] ? $this->connection->pconnect() : $this->connection->connect(); + $this->config->isPersisted() ? + $this->connection->pconnect() : + $this->connection->connect() + ; } if (false == $this->connection->isConnected()) { - $this->config['persisted'] ? $this->connection->preconnect() : $this->connection->reconnect(); + $this->config->isPersisted() ? + $this->connection->preconnect() : + $this->connection->reconnect() + ; } - return new AmqpContext(new \AMQPChannel($this->connection)); + return $this->connection; } } diff --git a/pkg/amqp-ext/AmqpConsumer.php b/pkg/amqp-ext/AmqpConsumer.php index ce60cb09a..700e8d77f 100644 --- a/pkg/amqp-ext/AmqpConsumer.php +++ b/pkg/amqp-ext/AmqpConsumer.php @@ -2,11 +2,14 @@ namespace Enqueue\AmqpExt; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\InvalidMessageException; -use Enqueue\Psr\Message; - -class AmqpConsumer implements Consumer +use Interop\Amqp\AmqpConsumer as InteropAmqpConsumer; +use Interop\Amqp\AmqpMessage as InteropAmqpMessage; +use Interop\Amqp\AmqpQueue as InteropAmqpQueue; +use Interop\Queue\Exception\InvalidMessageException; +use Interop\Queue\Message; +use Interop\Queue\Queue; + +class AmqpConsumer implements InteropAmqpConsumer { /** * @var AmqpContext @@ -14,7 +17,7 @@ class AmqpConsumer implements Consumer private $context; /** - * @var AmqpQueue + * @var InteropAmqpQueue */ private $queue; @@ -24,156 +27,119 @@ class AmqpConsumer implements Consumer private $extQueue; /** - * @var string + * @var int */ - private $consumerId; + private $flags; /** - * @var bool + * @var string */ - private $isInit; + private $consumerTag; - /** - * @param AmqpContext $context - * @param AmqpQueue $queue - */ - public function __construct(AmqpContext $context, AmqpQueue $queue) + public function __construct(AmqpContext $context, InteropAmqpQueue $queue) { $this->queue = $queue; $this->context = $context; - - $this->consumerId = uniqid('', true); - $this->isInit = false; + $this->flags = self::FLAG_NOPARAM; } - /** - * {@inheritdoc} - * - * @return AmqpQueue - */ - public function getQueue() + public function setConsumerTag(?string $consumerTag = null): void { - return $this->queue; + $this->consumerTag = $consumerTag; } - /** - * {@inheritdoc} - * - * @return AmqpMessage|null - */ - public function receive($timeout = 0) + public function getConsumerTag(): ?string { - /** @var \AMQPQueue $extQueue */ - $extConnection = $this->getExtQueue()->getChannel()->getConnection(); + return $this->consumerTag; + } - $originalTimeout = $extConnection->getReadTimeout(); - try { - $extConnection->setReadTimeout($timeout); + public function clearFlags(): void + { + $this->flags = self::FLAG_NOPARAM; + } - if (false == $this->isInit) { - $this->getExtQueue()->consume(null, AMQP_NOPARAM, $this->consumerId); + public function addFlag(int $flag): void + { + $this->flags |= $flag; + } - $this->isInit = true; - } + public function getFlags(): int + { + return $this->flags; + } - $message = null; + public function setFlags(int $flags): void + { + $this->flags = $flags; + } - $this->getExtQueue()->consume(function (\AMQPEnvelope $extEnvelope, \AMQPQueue $q) use (&$message) { - $message = $this->convertMessage($extEnvelope); + /** + * @return InteropAmqpQueue + */ + public function getQueue(): Queue + { + return $this->queue; + } - return false; - }, AMQP_JUST_CONSUME); + /** + * @return InteropAmqpMessage + */ + public function receive(int $timeout = 0): ?Message + { + $end = microtime(true) + ($timeout / 1000); - return $message; - } catch (\AMQPQueueException $e) { - if ('Consumer timeout exceed' == $e->getMessage()) { - return null; + while (0 === $timeout || microtime(true) < $end) { + if ($message = $this->receiveNoWait()) { + return $message; } - throw $e; - } finally { - $extConnection->setReadTimeout($originalTimeout); + usleep(100000); // 100ms } + + return null; } /** - * {@inheritdoc} - * - * @return AmqpMessage|null + * @return InteropAmqpMessage */ - public function receiveNoWait() + public function receiveNoWait(): ?Message { - if ($extMessage = $this->getExtQueue()->get()) { - return $this->convertMessage($extMessage); + if ($extMessage = $this->getExtQueue()->get(Flags::convertConsumerFlags($this->flags))) { + return $this->context->convertMessage($extMessage); } + + return null; } /** - * {@inheritdoc} - * - * @param AmqpMessage $message + * @param InteropAmqpMessage $message */ - public function acknowledge(Message $message) + public function acknowledge(Message $message): void { - InvalidMessageException::assertMessageInstanceOf($message, AmqpMessage::class); + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); $this->getExtQueue()->ack($message->getDeliveryTag()); } /** - * {@inheritdoc} - * - * @param AmqpMessage $message + * @param InteropAmqpMessage $message */ - public function reject(Message $message, $requeue = false) + public function reject(Message $message, bool $requeue = false): void { - InvalidMessageException::assertMessageInstanceOf($message, AmqpMessage::class); + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); $this->getExtQueue()->reject( $message->getDeliveryTag(), - $requeue ? AMQP_REQUEUE : AMQP_NOPARAM - ); - } - - /** - * @param \AMQPEnvelope $extEnvelope - * - * @return AmqpMessage - */ - private function convertMessage(\AMQPEnvelope $extEnvelope) - { - $message = new AmqpMessage( - $extEnvelope->getBody(), - $extEnvelope->getHeaders(), - [ - 'message_id' => $extEnvelope->getMessageId(), - 'correlation_id' => $extEnvelope->getCorrelationId(), - 'app_id' => $extEnvelope->getAppId(), - 'type' => $extEnvelope->getType(), - 'content_encoding' => $extEnvelope->getContentEncoding(), - 'content_type' => $extEnvelope->getContentType(), - 'expiration' => $extEnvelope->getExpiration(), - 'priority' => $extEnvelope->getPriority(), - 'reply_to' => $extEnvelope->getReplyTo(), - 'timestamp' => $extEnvelope->getTimeStamp(), - 'user_id' => $extEnvelope->getUserId(), - ] + $requeue ? \AMQP_REQUEUE : \AMQP_NOPARAM ); - $message->setRedelivered($extEnvelope->isRedelivery()); - $message->setDeliveryTag($extEnvelope->getDeliveryTag()); - - return $message; } - /** - * @return \AMQPQueue - */ - private function getExtQueue() + private function getExtQueue(): \AMQPQueue { if (false == $this->extQueue) { $extQueue = new \AMQPQueue($this->context->getExtChannel()); $extQueue->setName($this->queue->getQueueName()); - $extQueue->setFlags($this->queue->getFlags()); + $extQueue->setFlags(Flags::convertQueueFlags($this->queue->getFlags())); $extQueue->setArguments($this->queue->getArguments()); $this->extQueue = $extQueue; diff --git a/pkg/amqp-ext/AmqpContext.php b/pkg/amqp-ext/AmqpContext.php index 17274781d..c339dc0a1 100644 --- a/pkg/amqp-ext/AmqpContext.php +++ b/pkg/amqp-ext/AmqpContext.php @@ -2,159 +2,217 @@ namespace Enqueue\AmqpExt; -use Enqueue\Psr\Context; -use Enqueue\Psr\Destination; -use Enqueue\Psr\InvalidDestinationException; -use Enqueue\Psr\Topic; - -class AmqpContext implements Context +use Enqueue\AmqpTools\DelayStrategyAware; +use Enqueue\AmqpTools\DelayStrategyAwareTrait; +use Interop\Amqp\AmqpBind as InteropAmqpBind; +use Interop\Amqp\AmqpContext as InteropAmqpContext; +use Interop\Amqp\AmqpMessage as InteropAmqpMessage; +use Interop\Amqp\AmqpQueue as InteropAmqpQueue; +use Interop\Amqp\AmqpTopic as InteropAmqpTopic; +use Interop\Amqp\Impl\AmqpBind; +use Interop\Amqp\Impl\AmqpMessage; +use Interop\Amqp\Impl\AmqpQueue; +use Interop\Amqp\Impl\AmqpTopic; +use Interop\Queue\Consumer; +use Interop\Queue\Destination; +use Interop\Queue\Exception\Exception; +use Interop\Queue\Exception\InvalidDestinationException; +use Interop\Queue\Message; +use Interop\Queue\Producer; +use Interop\Queue\Queue; +use Interop\Queue\SubscriptionConsumer; +use Interop\Queue\Topic; + +class AmqpContext implements InteropAmqpContext, DelayStrategyAware { + use DelayStrategyAwareTrait; + /** * @var \AMQPChannel */ private $extChannel; /** - * @param \AMQPChannel $extChannel + * @var callable */ - public function __construct(\AMQPChannel $extChannel) - { - $this->extChannel = $extChannel; - } + private $extChannelFactory; /** - * {@inheritdoc} + * Callable must return instance of \AMQPChannel once called. * - * @return AmqpMessage + * @param \AMQPChannel|callable $extChannel */ - public function createMessage($body = '', array $properties = [], array $headers = []) + public function __construct($extChannel) { - return new AmqpMessage($body, $properties, $headers); + if ($extChannel instanceof \AMQPChannel) { + $this->extChannel = $extChannel; + } elseif (is_callable($extChannel)) { + $this->extChannelFactory = $extChannel; + } else { + throw new \InvalidArgumentException('The extChannel argument must be either AMQPChannel or callable that return AMQPChannel.'); + } } /** - * {@inheritdoc} - * - * @return AmqpTopic + * @return InteropAmqpMessage */ - public function createTopic($topicName) + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message { - return new AmqpTopic($topicName); + return new AmqpMessage($body, $properties, $headers); } /** - * @param AmqpTopic|Destination $destination + * @return InteropAmqpTopic */ - public function deleteTopic(Destination $destination) + public function createTopic(string $topicName): Topic { - InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpTopic::class); - - $extExchange = new \AMQPExchange($this->extChannel); - $extExchange->delete($destination->getTopicName(), $destination->getFlags()); + return new AmqpTopic($topicName); } - /** - * @param AmqpTopic|Destination $destination - */ - public function declareTopic(Destination $destination) + public function deleteTopic(InteropAmqpTopic $topic): void { - InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpTopic::class); + $extExchange = new \AMQPExchange($this->getExtChannel()); + $extExchange->delete($topic->getTopicName(), Flags::convertTopicFlags($topic->getFlags())); + } - $extExchange = new \AMQPExchange($this->extChannel); - $extExchange->setName($destination->getTopicName()); - $extExchange->setType($destination->getType()); - $extExchange->setArguments($destination->getArguments()); - $extExchange->setFlags($destination->getFlags()); + public function declareTopic(InteropAmqpTopic $topic): void + { + $extExchange = new \AMQPExchange($this->getExtChannel()); + $extExchange->setName($topic->getTopicName()); + $extExchange->setType($topic->getType()); + $extExchange->setArguments($topic->getArguments()); + $extExchange->setFlags(Flags::convertTopicFlags($topic->getFlags())); $extExchange->declareExchange(); } /** - * {@inheritdoc} - * - * @return AmqpQueue + * @return InteropAmqpQueue */ - public function createQueue($queueName) + public function createQueue(string $queueName): Queue { return new AmqpQueue($queueName); } - /** - * @param AmqpQueue|Destination $destination - */ - public function deleteQueue(Destination $destination) + public function deleteQueue(InteropAmqpQueue $queue): void { - InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpQueue::class); + $extQueue = new \AMQPQueue($this->getExtChannel()); + $extQueue->setName($queue->getQueueName()); + $extQueue->delete(Flags::convertQueueFlags($queue->getFlags())); + } + + public function declareQueue(InteropAmqpQueue $queue): int + { + $extQueue = new \AMQPQueue($this->getExtChannel()); + $extQueue->setName($queue->getQueueName()); + $extQueue->setArguments($queue->getArguments()); + $extQueue->setFlags(Flags::convertQueueFlags($queue->getFlags())); - $extQueue = new \AMQPQueue($this->extChannel); - $extQueue->setName($destination->getQueueName()); - $extQueue->delete($destination->getFlags()); + return $extQueue->declareQueue(); } /** - * @param AmqpQueue|Destination $destination + * @param InteropAmqpQueue $queue */ - public function declareQueue(Destination $destination) + public function purgeQueue(Queue $queue): void { - InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpQueue::class); + InvalidDestinationException::assertDestinationInstanceOf($queue, InteropAmqpQueue::class); - $extQueue = new \AMQPQueue($this->extChannel); - $extQueue->setFlags($destination->getFlags()); - $extQueue->setArguments($destination->getArguments()); + $amqpQueue = new \AMQPQueue($this->getExtChannel()); + $amqpQueue->setName($queue->getQueueName()); + $amqpQueue->purge(); + } - if ($destination->getQueueName()) { - $extQueue->setName($destination->getQueueName()); + public function bind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to bind queue to queue. It is possible to bind topic to queue or topic to topic'); } - $extQueue->declareQueue(); + // bind exchange to exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $exchange = new \AMQPExchange($this->getExtChannel()); + $exchange->setName($bind->getSource()->getTopicName()); + $exchange->bind($bind->getTarget()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); + // bind queue to exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $queue = new \AMQPQueue($this->getExtChannel()); + $queue->setName($bind->getSource()->getQueueName()); + $queue->bind($bind->getTarget()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); + // bind exchange to queue + } else { + $queue = new \AMQPQueue($this->getExtChannel()); + $queue->setName($bind->getTarget()->getQueueName()); + $queue->bind($bind->getSource()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); + } + } + + public function unbind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to unbind queue to queue. It is possible to unbind topic from queue or topic from topic'); + } - if (false == $destination->getQueueName()) { - $destination->setQueueName($extQueue->getName()); + // unbind exchange from exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $exchange = new \AMQPExchange($this->getExtChannel()); + $exchange->setName($bind->getSource()->getTopicName()); + $exchange->unbind($bind->getTarget()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); + // unbind queue from exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $queue = new \AMQPQueue($this->getExtChannel()); + $queue->setName($bind->getSource()->getQueueName()); + $queue->unbind($bind->getTarget()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); + // unbind exchange from queue + } else { + $queue = new \AMQPQueue($this->getExtChannel()); + $queue->setName($bind->getTarget()->getQueueName()); + $queue->unbind($bind->getSource()->getTopicName(), $bind->getRoutingKey(), $bind->getArguments()); } } /** - * {@inheritdoc} - * - * @return AmqpQueue + * @return InteropAmqpQueue */ - public function createTemporaryQueue() + public function createTemporaryQueue(): Queue { - $queue = $this->createQueue(null); - $queue->addFlag(AMQP_EXCLUSIVE); + $extQueue = new \AMQPQueue($this->getExtChannel()); + $extQueue->setFlags(\AMQP_EXCLUSIVE); - $this->declareQueue($queue); + $extQueue->declareQueue(); + + $queue = $this->createQueue($extQueue->getName()); + $queue->addFlag(InteropAmqpQueue::FLAG_EXCLUSIVE); return $queue; } /** - * {@inheritdoc} - * * @return AmqpProducer */ - public function createProducer() + public function createProducer(): Producer { - return new AmqpProducer($this->extChannel); + $producer = new AmqpProducer($this->getExtChannel(), $this); + $producer->setDelayStrategy($this->delayStrategy); + + return $producer; } /** - * {@inheritdoc} - * - * @param Destination|AmqpQueue $destination + * @param InteropAmqpQueue $destination * * @return AmqpConsumer */ - public function createConsumer(Destination $destination) + public function createConsumer(Destination $destination): Consumer { $destination instanceof Topic - ? InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpTopic::class) - : InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpQueue::class) + ? InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpTopic::class) + : InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpQueue::class) ; if ($destination instanceof AmqpTopic) { $queue = $this->createTemporaryQueue(); - $this->bind($destination, $queue); + $this->bind(new AmqpBind($destination, $queue, $queue->getQueueName())); return new AmqpConsumer($this, $queue); } @@ -162,41 +220,64 @@ public function createConsumer(Destination $destination) return new AmqpConsumer($this, $destination); } - public function close() + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new AmqpSubscriptionConsumer($this); + } + + public function close(): void { - $extConnection = $this->extChannel->getConnection(); + $extConnection = $this->getExtChannel()->getConnection(); if ($extConnection->isConnected()) { $extConnection->isPersistent() ? $extConnection->pdisconnect() : $extConnection->disconnect(); } } - /** - * @param AmqpTopic|Destination $source - * @param AmqpQueue|Destination $target - */ - public function bind(Destination $source, Destination $target) + public function setQos(int $prefetchSize, int $prefetchCount, bool $global): void { - InvalidDestinationException::assertDestinationInstanceOf($source, AmqpTopic::class); - InvalidDestinationException::assertDestinationInstanceOf($target, AmqpQueue::class); - - $amqpQueue = new \AMQPQueue($this->extChannel); - $amqpQueue->setName($target->getQueueName()); - $amqpQueue->bind($source->getTopicName(), $amqpQueue->getName(), $target->getBindArguments()); + $this->getExtChannel()->qos($prefetchSize, $prefetchCount); } - /** - * @return \AMQPConnection - */ - public function getExtConnection() + public function getExtChannel(): \AMQPChannel { - return $this->extChannel->getConnection(); + if (false == $this->extChannel) { + $extChannel = call_user_func($this->extChannelFactory); + if (false == $extChannel instanceof \AMQPChannel) { + throw new \LogicException(sprintf('The factory must return instance of AMQPChannel. It returns %s', is_object($extChannel) ? $extChannel::class : gettype($extChannel))); + } + + $this->extChannel = $extChannel; + } + + return $this->extChannel; } /** - * @return mixed + * @internal It must be used here and in the consumer only */ - public function getExtChannel() + public function convertMessage(\AMQPEnvelope $extEnvelope): InteropAmqpMessage { - return $this->extChannel; + $message = new AmqpMessage( + $extEnvelope->getBody(), + $extEnvelope->getHeaders(), + [ + 'message_id' => $extEnvelope->getMessageId(), + 'correlation_id' => $extEnvelope->getCorrelationId(), + 'app_id' => $extEnvelope->getAppId(), + 'type' => $extEnvelope->getType(), + 'content_encoding' => $extEnvelope->getContentEncoding(), + 'content_type' => $extEnvelope->getContentType(), + 'expiration' => $extEnvelope->getExpiration(), + 'priority' => $extEnvelope->getPriority(), + 'reply_to' => $extEnvelope->getReplyTo(), + 'timestamp' => $extEnvelope->getTimeStamp(), + 'user_id' => $extEnvelope->getUserId(), + ] + ); + $message->setRedelivered($extEnvelope->isRedelivery()); + $message->setDeliveryTag($extEnvelope->getDeliveryTag()); + $message->setRoutingKey($extEnvelope->getRoutingKey()); + + return $message; } } diff --git a/pkg/amqp-ext/AmqpMessage.php b/pkg/amqp-ext/AmqpMessage.php deleted file mode 100644 index d5992237b..000000000 --- a/pkg/amqp-ext/AmqpMessage.php +++ /dev/null @@ -1,250 +0,0 @@ -body = $body; - $this->properties = $properties; - $this->headers = $headers; - - $this->redelivered = false; - $this->flags = AMQP_NOPARAM; - } - - /** - * {@inheritdoc} - */ - public function getBody() - { - return $this->body; - } - - /** - * {@inheritdoc} - */ - public function setBody($body) - { - $this->body = $body; - } - - /** - * {@inheritdoc} - */ - public function setProperties(array $properties) - { - $this->properties = $properties; - } - - /** - * {@inheritdoc} - */ - public function getProperties() - { - return $this->properties; - } - - /** - * {@inheritdoc} - */ - public function setProperty($name, $value) - { - $this->properties[$name] = $value; - } - - /** - * {@inheritdoc} - */ - public function getProperty($name, $default = null) - { - return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; - } - - /** - * {@inheritdoc} - */ - public function setHeaders(array $headers) - { - $this->headers = $headers; - } - - /** - * {@inheritdoc} - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * {@inheritdoc} - */ - public function setHeader($name, $value) - { - $this->headers[$name] = $value; - } - - /** - * {@inheritdoc} - */ - public function getHeader($name, $default = null) - { - return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; - } - - /** - * {@inheritdoc} - */ - public function setRedelivered($redelivered) - { - $this->redelivered = (bool) $redelivered; - } - - /** - * {@inheritdoc} - */ - public function isRedelivered() - { - return $this->redelivered; - } - - /** - * {@inheritdoc} - */ - public function setCorrelationId($correlationId) - { - $this->setHeader('correlation_id', $correlationId); - } - - /** - * {@inheritdoc} - */ - public function getCorrelationId() - { - return $this->getHeader('correlation_id'); - } - - /** - * {@inheritdoc} - */ - public function setMessageId($messageId) - { - $this->setHeader('message_id', $messageId); - } - - /** - * {@inheritdoc} - */ - public function getMessageId() - { - return $this->getHeader('message_id'); - } - - /** - * {@inheritdoc} - */ - public function getTimestamp() - { - return $this->getHeader('timestamp'); - } - - /** - * {@inheritdoc} - */ - public function setTimestamp($timestamp) - { - $this->setHeader('timestamp', $timestamp); - } - - /** - * {@inheritdoc} - */ - public function setReplyTo($replyTo) - { - $this->setHeader('reply_to', $replyTo); - } - - /** - * {@inheritdoc} - */ - public function getReplyTo() - { - return $this->getHeader('reply_to'); - } - - /** - * @return null|string - */ - public function getDeliveryTag() - { - return $this->deliveryTag; - } - - /** - * @param null|string $deliveryTag - */ - public function setDeliveryTag($deliveryTag) - { - $this->deliveryTag = $deliveryTag; - } - - public function clearFlags() - { - $this->flags = AMQP_NOPARAM; - } - - /** - * @param int $flag - */ - public function addFlag($flag) - { - $this->flags = $this->flags | $flag; - } - - /** - * @return int - */ - public function getFlags() - { - return $this->flags; - } -} diff --git a/pkg/amqp-ext/AmqpProducer.php b/pkg/amqp-ext/AmqpProducer.php index 39d9d3ad1..fc55ca29e 100644 --- a/pkg/amqp-ext/AmqpProducer.php +++ b/pkg/amqp-ext/AmqpProducer.php @@ -2,71 +2,157 @@ namespace Enqueue\AmqpExt; -use Enqueue\Psr\Destination; -use Enqueue\Psr\InvalidDestinationException; -use Enqueue\Psr\InvalidMessageException; -use Enqueue\Psr\Message; -use Enqueue\Psr\Producer; -use Enqueue\Psr\Topic; - -class AmqpProducer implements Producer +use Enqueue\AmqpTools\DelayStrategyAware; +use Enqueue\AmqpTools\DelayStrategyAwareTrait; +use Interop\Amqp\AmqpDestination; +use Interop\Amqp\AmqpMessage; +use Interop\Amqp\AmqpProducer as InteropAmqpProducer; +use Interop\Amqp\AmqpQueue; +use Interop\Amqp\AmqpTopic; +use Interop\Queue\Destination; +use Interop\Queue\Exception\DeliveryDelayNotSupportedException; +use Interop\Queue\Exception\Exception; +use Interop\Queue\Exception\InvalidDestinationException; +use Interop\Queue\Exception\InvalidMessageException; +use Interop\Queue\Message; +use Interop\Queue\Producer; +use Interop\Queue\Topic; + +class AmqpProducer implements InteropAmqpProducer, DelayStrategyAware { + use DelayStrategyAwareTrait; + + /** + * @var int|null + */ + private $priority; + + /** + * @var int|float|null + */ + private $timeToLive; + /** * @var \AMQPChannel */ private $amqpChannel; /** - * @param \AMQPChannel $ampqChannel + * @var AmqpContext + */ + private $context; + + /** + * @var int */ - public function __construct(\AMQPChannel $ampqChannel) + private $deliveryDelay; + + public function __construct(\AMQPChannel $ampqChannel, AmqpContext $context) { $this->amqpChannel = $ampqChannel; + $this->context = $context; } /** - * {@inheritdoc} - * * @param AmqpTopic|AmqpQueue $destination * @param AmqpMessage $message */ - public function send(Destination $destination, Message $message) + public function send(Destination $destination, Message $message): void { $destination instanceof Topic ? InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpTopic::class) - : InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpQueue::class) - ; + : InvalidDestinationException::assertDestinationInstanceOf($destination, AmqpQueue::class); InvalidMessageException::assertMessageInstanceOf($message, AmqpMessage::class); + try { + $this->doSend($destination, $message); + } catch (\Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $this->delayStrategy) { + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } + + private function doSend(AmqpDestination $destination, AmqpMessage $message): void + { + if (null !== $this->priority && null === $message->getPriority()) { + $message->setPriority($this->priority); + } + + if (null !== $this->timeToLive && null === $message->getExpiration()) { + $message->setExpiration($this->timeToLive); + } + $amqpAttributes = $message->getHeaders(); if ($message->getProperties()) { $amqpAttributes['headers'] = $message->getProperties(); } - if ($destination instanceof AmqpTopic) { + if ($this->deliveryDelay) { + $this->delayStrategy->delayMessage($this->context, $destination, $message, $this->deliveryDelay); + } elseif ($destination instanceof AmqpTopic) { $amqpExchange = new \AMQPExchange($this->amqpChannel); $amqpExchange->setType($destination->getType()); $amqpExchange->setName($destination->getTopicName()); - $amqpExchange->setFlags($destination->getFlags()); + $amqpExchange->setFlags(Flags::convertTopicFlags($destination->getFlags())); $amqpExchange->setArguments($destination->getArguments()); $amqpExchange->publish( $message->getBody(), - $destination->getRoutingKey(), - $message->getFlags(), + $message->getRoutingKey(), + Flags::convertMessageFlags($message->getFlags()), $amqpAttributes ); } else { + /** @var AmqpQueue $destination */ $amqpExchange = new \AMQPExchange($this->amqpChannel); - $amqpExchange->setType(AMQP_EX_TYPE_DIRECT); + $amqpExchange->setType(\AMQP_EX_TYPE_DIRECT); $amqpExchange->setName(''); $amqpExchange->publish( $message->getBody(), $destination->getQueueName(), - $message->getFlags(), + Flags::convertMessageFlags($message->getFlags()), $amqpAttributes ); } diff --git a/pkg/amqp-ext/AmqpQueue.php b/pkg/amqp-ext/AmqpQueue.php deleted file mode 100644 index b11454a00..000000000 --- a/pkg/amqp-ext/AmqpQueue.php +++ /dev/null @@ -1,130 +0,0 @@ -name = $name; - - $this->arguments = []; - $this->bindArguments = []; - $this->flags = AMQP_NOPARAM; - } - - /** - * {@inheritdoc} - */ - public function getQueueName() - { - return $this->name; - } - - /** - * @param string $name - */ - public function setQueueName($name) - { - $this->name = $name; - } - - /** - * @return string - */ - public function getConsumerTag() - { - return $this->consumerTag; - } - - /** - * @param string $consumerTag - */ - public function setConsumerTag($consumerTag) - { - $this->consumerTag = $consumerTag; - } - - /** - * @param int $flag - */ - public function addFlag($flag) - { - $this->flags |= $flag; - } - - public function clearFlags() - { - $this->flags = AMQP_NOPARAM; - } - - /** - * @return int - */ - public function getFlags() - { - return $this->flags; - } - - /** - * @return array - */ - public function getArguments() - { - return $this->arguments; - } - - /** - * @param array $arguments - */ - public function setArguments(array $arguments = null) - { - $this->arguments = $arguments; - } - - /** - * @return array - */ - public function getBindArguments() - { - return $this->bindArguments; - } - - /** - * @param array $arguments - */ - public function setBindArguments(array $arguments = null) - { - $this->bindArguments = $arguments; - } -} diff --git a/pkg/amqp-ext/AmqpSubscriptionConsumer.php b/pkg/amqp-ext/AmqpSubscriptionConsumer.php new file mode 100644 index 000000000..3d0faccb7 --- /dev/null +++ b/pkg/amqp-ext/AmqpSubscriptionConsumer.php @@ -0,0 +1,134 @@ +context = $context; + + $this->subscribers = []; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('There is no subscribers. Consider calling basicConsumeSubscribe before consuming'); + } + + if (false == (version_compare(phpversion('amqp'), '1.9.1', '>=') || '1.9.1-dev' == phpversion('amqp'))) { + // @see https://github.com/php-enqueue/enqueue-dev/issues/110 and https://github.com/pdezwart/php-amqp/issues/281 + throw new \LogicException('The AMQP extension "basic_consume" method does not work properly prior 1.9.1 version.'); + } + + /** @var \AMQPQueue $extQueue */ + $extConnection = $this->context->getExtChannel()->getConnection(); + + $originalTimeout = $extConnection->getReadTimeout(); + try { + $extConnection->setReadTimeout($timeout / 1000); + + reset($this->subscribers); + /** @var $consumer AmqpConsumer */ + list($consumer) = current($this->subscribers); + + $extQueue = new \AMQPQueue($this->context->getExtChannel()); + $extQueue->setName($consumer->getQueue()->getQueueName()); + $extQueue->consume(function (\AMQPEnvelope $extEnvelope, \AMQPQueue $q) use ($originalTimeout, $extConnection) { + $consumeTimeout = $extConnection->getReadTimeout(); + try { + $extConnection->setReadTimeout($originalTimeout); + + $message = $this->context->convertMessage($extEnvelope); + $message->setConsumerTag($q->getConsumerTag()); + + /** + * @var AmqpConsumer + * @var callable $callback + */ + list($consumer, $callback) = $this->subscribers[$q->getConsumerTag()]; + + return call_user_func($callback, $message, $consumer); + } finally { + $extConnection->setReadTimeout($consumeTimeout); + } + }, \AMQP_JUST_CONSUME); + } catch (\AMQPQueueException $e) { + if ('Consumer timeout exceed' == $e->getMessage()) { + return; + } + + throw $e; + } finally { + $extConnection->setReadTimeout($originalTimeout); + } + } + + /** + * @param AmqpConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if ($consumer->getConsumerTag() && array_key_exists($consumer->getConsumerTag(), $this->subscribers)) { + return; + } + + $extQueue = new \AMQPQueue($this->context->getExtChannel()); + $extQueue->setName($consumer->getQueue()->getQueueName()); + + $extQueue->consume(null, Flags::convertConsumerFlags($consumer->getFlags()), $consumer->getConsumerTag()); + + $consumerTag = $extQueue->getConsumerTag(); + $consumer->setConsumerTag($consumerTag); + $this->subscribers[$consumerTag] = [$consumer, $callback, $extQueue]; + } + + /** + * @param AmqpConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if (false == $consumer->getConsumerTag()) { + return; + } + + $consumerTag = $consumer->getConsumerTag(); + $consumer->setConsumerTag(null); + + list($consumer, $callback, $extQueue) = $this->subscribers[$consumerTag]; + + $extQueue->cancel($consumerTag); + unset($this->subscribers[$consumerTag]); + } + + public function unsubscribeAll(): void + { + foreach ($this->subscribers as list($consumer)) { + $this->unsubscribe($consumer); + } + } +} diff --git a/pkg/amqp-ext/AmqpTopic.php b/pkg/amqp-ext/AmqpTopic.php deleted file mode 100644 index fbe45a9f7..000000000 --- a/pkg/amqp-ext/AmqpTopic.php +++ /dev/null @@ -1,130 +0,0 @@ -name = $name; - - $this->type = AMQP_EX_TYPE_DIRECT; - $this->flags = AMQP_NOPARAM; - $this->arguments = []; - } - - /** - * {@inheritdoc} - */ - public function getTopicName() - { - return $this->name; - } - - /** - * @param string $name - */ - public function setTopicName($name) - { - $this->name = $name; - } - - /** - * @return string - */ - public function getType() - { - return $this->type; - } - - /** - * @param string $type - */ - public function setType($type) - { - $this->type = $type; - } - - /** - * @param int $flag - */ - public function addFlag($flag) - { - $this->flags |= $flag; - } - - public function clearFlags() - { - $this->flags = AMQP_NOPARAM; - } - - /** - * @return int - */ - public function getFlags() - { - return $this->flags; - } - - /** - * @return array - */ - public function getArguments() - { - return $this->arguments; - } - - /** - * @param array $arguments - */ - public function setArguments(array $arguments = null) - { - $this->arguments = $arguments; - } - - /** - * @return string - */ - public function getRoutingKey() - { - return $this->routingKey; - } - - /** - * @param string $routingKey - */ - public function setRoutingKey($routingKey) - { - $this->routingKey = $routingKey; - } -} diff --git a/pkg/amqp-ext/Client/AmqpDriver.php b/pkg/amqp-ext/Client/AmqpDriver.php deleted file mode 100644 index 560ef9036..000000000 --- a/pkg/amqp-ext/Client/AmqpDriver.php +++ /dev/null @@ -1,236 +0,0 @@ -context = $context; - $this->config = $config; - $this->queueMetaRegistry = $queueMetaRegistry; - - $this->priorityMap = [ - MessagePriority::VERY_LOW => 0, - MessagePriority::LOW => 1, - MessagePriority::NORMAL => 2, - MessagePriority::HIGH => 3, - MessagePriority::VERY_HIGH => 4, - ]; - } - - /** - * {@inheritdoc} - */ - public function sendToRouter(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_TOPIC_NAME)) { - throw new \LogicException('Topic name parameter is required but is not set'); - } - - $topic = $this->createRouterTopic(); - $transportMessage = $this->createTransportMessage($message); - - $this->context->createProducer()->send($topic, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function sendToProcessor(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_PROCESSOR_NAME)) { - throw new \LogicException('Processor name parameter is required but is not set'); - } - - if (false == $queueName = $message->getProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME)) { - throw new \LogicException('Queue name parameter is required but is not set'); - } - - $transportMessage = $this->createTransportMessage($message); - $destination = $this->createQueue($queueName); - - $this->context->createProducer()->send($destination, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function setupBroker(LoggerInterface $logger = null) - { - $logger = $logger ?: new NullLogger(); - $log = function ($text, ...$args) use ($logger) { - $logger->debug(sprintf('[AmqpDriver] '.$text, ...$args)); - }; - - // setup router - $routerTopic = $this->createRouterTopic(); - $routerQueue = $this->createQueue($this->config->getRouterQueueName()); - - $log('Declare router exchange: %s', $routerTopic->getTopicName()); - $this->context->declareTopic($routerTopic); - $log('Declare router queue: %s', $routerQueue->getQueueName()); - $this->context->declareQueue($routerQueue); - $log('Bind router queue to exchange: %s -> %s', $routerQueue->getQueueName(), $routerTopic->getTopicName()); - $this->context->bind($routerTopic, $routerQueue); - - // setup queues - foreach ($this->queueMetaRegistry->getQueuesMeta() as $meta) { - $queue = $this->createQueue($meta->getClientName()); - - $log('Declare processor queue: %s', $queue->getQueueName()); - $this->context->declareQueue($queue); - } - } - - /** - * {@inheritdoc} - * - * @return AmqpQueue - */ - public function createQueue($queueName) - { - $queue = $this->context->createQueue($this->config->createTransportQueueName($queueName)); - $queue->addFlag(AMQP_DURABLE); - $queue->setArguments(['x-max-priority' => 4]); - - return $queue; - } - - /** - * {@inheritdoc} - * - * @return AmqpMessage - */ - public function createTransportMessage(Message $message) - { - $headers = $message->getHeaders(); - $properties = $message->getProperties(); - - $headers['content_type'] = $message->getContentType(); - - if ($message->getExpire()) { - $headers['expiration'] = (string) ($message->getExpire() * 1000); - } - - if ($priority = $message->getPriority()) { - if (false == array_key_exists($priority, $this->priorityMap)) { - throw new \InvalidArgumentException(sprintf( - 'Given priority could not be converted to client\'s one. Got: %s', - $priority - )); - } - - $headers['priority'] = $this->priorityMap[$priority]; - } - - $headers['delivery_mode'] = DeliveryMode::PERSISTENT; - - $transportMessage = $this->context->createMessage(); - $transportMessage->setBody($message->getBody()); - $transportMessage->setHeaders($headers); - $transportMessage->setProperties($properties); - $transportMessage->setMessageId($message->getMessageId()); - $transportMessage->setTimestamp($message->getTimestamp()); - - return $transportMessage; - } - - /** - * @param AmqpMessage $message - * - * {@inheritdoc} - */ - public function createClientMessage(TransportMessage $message) - { - $clientMessage = new Message(); - - $clientMessage->setBody($message->getBody()); - $clientMessage->setHeaders($message->getHeaders()); - $clientMessage->setProperties($message->getProperties()); - - $clientMessage->setContentType($message->getHeader('content_type')); - - if ($expiration = $message->getHeader('expiration')) { - if (false == is_numeric($expiration)) { - throw new \LogicException(sprintf('expiration header is not numeric. "%s"', $expiration)); - } - - $clientMessage->setExpire((int) ((int) $expiration) / 1000); - } - - if ($priority = $message->getHeader('priority')) { - if (false === $clientPriority = array_search($priority, $this->priorityMap, true)) { - throw new \LogicException(sprintf('Cant convert transport priority to client: "%s"', $priority)); - } - - $clientMessage->setPriority($clientPriority); - } - - $clientMessage->setMessageId($message->getMessageId()); - $clientMessage->setTimestamp($message->getTimestamp()); - - return $clientMessage; - } - - /** - * @return Config - */ - public function getConfig() - { - return $this->config; - } - - /** - * @return AmqpTopic - */ - private function createRouterTopic() - { - $topic = $this->context->createTopic( - $this->config->createTransportRouterTopicName($this->config->getRouterTopicName()) - ); - $topic->setType(AMQP_EX_TYPE_FANOUT); - $topic->addFlag(AMQP_DURABLE); - - return $topic; - } -} diff --git a/pkg/amqp-ext/Client/RabbitMqDriver.php b/pkg/amqp-ext/Client/RabbitMqDriver.php deleted file mode 100644 index cd6b189e3..000000000 --- a/pkg/amqp-ext/Client/RabbitMqDriver.php +++ /dev/null @@ -1,160 +0,0 @@ -config = $config; - $this->context = $context; - $this->queueMetaRegistry = $queueMetaRegistry; - } - - /** - * {@inheritdoc} - */ - public function sendToProcessor(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_PROCESSOR_NAME)) { - throw new \LogicException('Processor name parameter is required but is not set'); - } - - if (false == $queueName = $message->getProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME)) { - throw new \LogicException('Queue name parameter is required but is not set'); - } - - $transportMessage = $this->createTransportMessage($message); - $destination = $this->createQueue($queueName); - - if ($message->getDelay()) { - $destination = $this->createDelayedTopic($destination); - } - - $this->context->createProducer()->send($destination, $transportMessage); - } - - /** - * {@inheritdoc} - * - * @return AmqpMessage - */ - public function createTransportMessage(Message $message) - { - $transportMessage = parent::createTransportMessage($message); - - if ($message->getDelay()) { - if (false == $this->config->getTransportOption('delay_plugin_installed', false)) { - throw new LogicException('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); - } - - $transportMessage->setProperty('x-delay', (string) ($message->getDelay() * 1000)); - } - - return $transportMessage; - } - - /** - * @param AmqpMessage $message - * - * {@inheritdoc} - */ - public function createClientMessage(TransportMessage $message) - { - $clientMessage = parent::createClientMessage($message); - - if ($delay = $message->getProperty('x-delay')) { - if (false == is_numeric($delay)) { - throw new \LogicException(sprintf('x-delay header is not numeric. "%s"', $delay)); - } - - $clientMessage->setDelay((int) ((int) $delay) / 1000); - } - - return $clientMessage; - } - - /** - * {@inheritdoc} - */ - public function setupBroker(LoggerInterface $logger = null) - { - $logger = $logger ?: new NullLogger(); - - parent::setupBroker($logger); - - $log = function ($text, ...$args) use ($logger) { - $logger->debug(sprintf('[RabbitMqDriver] '.$text, ...$args)); - }; - - // setup delay exchanges - if ($this->config->getTransportOption('delay_plugin_installed', false)) { - foreach ($this->queueMetaRegistry->getQueuesMeta() as $meta) { - $queue = $this->createQueue($meta->getClientName()); - - $delayTopic = $this->createDelayedTopic($queue); - - $log('Declare delay exchange: %s', $delayTopic->getTopicName()); - $this->context->declareTopic($delayTopic); - - $log('Bind processor queue to delay exchange: %s -> %s', $queue->getQueueName(), $delayTopic->getTopicName()); - $this->context->bind($delayTopic, $queue); - } - } - } - - /** - * @param AmqpQueue $queue - * - * @return AmqpTopic - */ - private function createDelayedTopic(AmqpQueue $queue) - { - $queueName = $queue->getQueueName(); - - // in order to use delay feature make sure the rabbitmq_delayed_message_exchange plugin is installed. - $delayTopic = $this->context->createTopic($queueName.'.delayed'); - $delayTopic->setRoutingKey($queueName); - $delayTopic->setType('x-delayed-message'); - $delayTopic->addFlag(AMQP_DURABLE); - $delayTopic->setArguments([ - 'x-delayed-type' => 'direct', - ]); - - return $delayTopic; - } -} diff --git a/pkg/amqp-ext/Flags.php b/pkg/amqp-ext/Flags.php new file mode 100644 index 000000000..2054f5526 --- /dev/null +++ b/pkg/amqp-ext/Flags.php @@ -0,0 +1,99 @@ +Supporting Enqueue + +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + # AMQP Transport [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/amqp-ext.png?branch=master)](https://travis-ci.org/php-enqueue/amqp-ext) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-ext/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-ext/actions?query=workflow%3ACI) [![Total Downloads](https://poser.pugx.org/enqueue/amqp-ext/d/total.png)](https://packagist.org/packages/enqueue/amqp-ext) [![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-ext/version.png)](https://packagist.org/packages/enqueue/amqp-ext) - -This is an implementation of PSR specification. It allows you to send and consume message via AMQP protocol. + +This is an implementation of [amqp interop](https://github.com/queue-interop/amqp-interop). It uses PHP [amqp extension](https://github.com/pdezwart/php-amqp) internally. ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/amqp/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/amqp-ext/Symfony/AmqpTransportFactory.php b/pkg/amqp-ext/Symfony/AmqpTransportFactory.php deleted file mode 100644 index 996f4613d..000000000 --- a/pkg/amqp-ext/Symfony/AmqpTransportFactory.php +++ /dev/null @@ -1,125 +0,0 @@ -name = $name; - } - - /** - * {@inheritdoc} - */ - public function addConfiguration(ArrayNodeDefinition $builder) - { - $builder - ->children() - ->scalarNode('host') - ->defaultValue('localhost') - ->cannotBeEmpty() - ->info('The host to connect too. Note: Max 1024 characters') - ->end() - ->scalarNode('port') - ->defaultValue(5672) - ->cannotBeEmpty() - ->info('Port on the host.') - ->end() - ->scalarNode('login') - ->defaultValue('guest') - ->cannotBeEmpty() - ->info('The login name to use. Note: Max 128 characters.') - ->end() - ->scalarNode('password') - ->defaultValue('guest') - ->cannotBeEmpty() - ->info('Password. Note: Max 128 characters.') - ->end() - ->scalarNode('vhost') - ->defaultValue('/') - ->cannotBeEmpty() - ->info('The virtual host on the host. Note: Max 128 characters.') - ->end() - ->integerNode('connect_timeout') - ->min(0) - ->info('Connection timeout. Note: 0 or greater seconds. May be fractional.') - ->end() - ->integerNode('read_timeout') - ->min(0) - ->info('Timeout in for income activity. Note: 0 or greater seconds. May be fractional.') - ->end() - ->integerNode('write_timeout') - ->min(0) - ->info('Timeout in for outcome activity. Note: 0 or greater seconds. May be fractional.') - ->end() - ->booleanNode('persisted') - ->defaultFalse() - ->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createContext(ContainerBuilder $container, array $config) - { - $factory = new Definition(AmqpConnectionFactory::class); - $factory->setPublic(false); - $factory->setArguments([$config]); - - $factoryId = sprintf('enqueue.transport.%s.connection_factory', $this->getName()); - $container->setDefinition($factoryId, $factory); - - $context = new Definition(AmqpContext::class); - $context->setFactory([new Reference($factoryId), 'createContext']); - - $contextId = sprintf('enqueue.transport.%s.context', $this->getName()); - $container->setDefinition($contextId, $context); - - return $contextId; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $driver = new Definition(AmqpDriver::class); - $driver->setArguments([ - new Reference(sprintf('enqueue.transport.%s.context', $this->getName())), - new Reference('enqueue.client.config'), - new Reference('enqueue.client.meta.queue_meta_registry'), - ]); - - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $container->setDefinition($driverId, $driver); - - return $driverId; - } - - /** - * {@inheritdoc} - */ - public function getName() - { - return $this->name; - } -} diff --git a/pkg/amqp-ext/Symfony/RabbitMqTransportFactory.php b/pkg/amqp-ext/Symfony/RabbitMqTransportFactory.php deleted file mode 100644 index 5444b4591..000000000 --- a/pkg/amqp-ext/Symfony/RabbitMqTransportFactory.php +++ /dev/null @@ -1,53 +0,0 @@ -children() - ->booleanNode('delay_plugin_installed') - ->defaultFalse() - ->info('The option tells whether RabbitMQ broker has delay plugin installed or not') - ->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $driver = new Definition(RabbitMqDriver::class); - $driver->setArguments([ - new Reference(sprintf('enqueue.transport.%s.context', $this->getName())), - new Reference('enqueue.client.config'), - new Reference('enqueue.client.meta.queue_meta_registry'), - ]); - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $container->setDefinition($driverId, $driver); - - return $driverId; - } -} diff --git a/pkg/amqp-ext/Tests/AmqpConnectionFactoryTest.php b/pkg/amqp-ext/Tests/AmqpConnectionFactoryTest.php new file mode 100644 index 000000000..7ea58f947 --- /dev/null +++ b/pkg/amqp-ext/Tests/AmqpConnectionFactoryTest.php @@ -0,0 +1,41 @@ +assertClassImplements(ConnectionFactory::class, AmqpConnectionFactory::class); + } + + public function testShouldSetRabbitMqDlxDelayStrategyIfRabbitMqSchemeExtensionPresent() + { + $factory = new AmqpConnectionFactory('amqp+rabbitmq:'); + + $this->assertAttributeInstanceOf(RabbitMqDlxDelayStrategy::class, 'delayStrategy', $factory); + } + + public function testShouldCreateLazyContext() + { + $factory = new AmqpConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(AmqpContext::class, $context); + + $this->assertAttributeEquals(null, 'extChannel', $context); + self::assertIsCallable($this->readAttribute($context, 'extChannelFactory')); + } +} diff --git a/pkg/amqp-ext/Tests/AmqpConsumerTest.php b/pkg/amqp-ext/Tests/AmqpConsumerTest.php new file mode 100644 index 000000000..1dcc0f349 --- /dev/null +++ b/pkg/amqp-ext/Tests/AmqpConsumerTest.php @@ -0,0 +1,28 @@ +assertClassImplements(Consumer::class, AmqpConsumer::class); + } + + /** + * @return MockObject|AmqpContext + */ + private function createContext() + { + return $this->createMock(AmqpContext::class); + } +} diff --git a/pkg/amqp-ext/Tests/AmqpContextTest.php b/pkg/amqp-ext/Tests/AmqpContextTest.php index 29be5be40..2b03bb3d2 100644 --- a/pkg/amqp-ext/Tests/AmqpContextTest.php +++ b/pkg/amqp-ext/Tests/AmqpContextTest.php @@ -4,28 +4,36 @@ use Enqueue\AmqpExt\AmqpConsumer; use Enqueue\AmqpExt\AmqpContext; -use Enqueue\AmqpExt\AmqpMessage; use Enqueue\AmqpExt\AmqpProducer; -use Enqueue\AmqpExt\AmqpQueue; -use Enqueue\AmqpExt\AmqpTopic; -use Enqueue\Psr\Context; -use Enqueue\Psr\InvalidDestinationException; +use Enqueue\AmqpExt\AmqpSubscriptionConsumer; +use Enqueue\Null\NullQueue; +use Enqueue\Null\NullTopic; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullQueue; -use Enqueue\Transport\Null\NullTopic; - -class AmqpContextTest extends \PHPUnit_Framework_TestCase +use Enqueue\Test\ReadAttributeTrait; +use Interop\Amqp\Impl\AmqpMessage; +use Interop\Amqp\Impl\AmqpQueue; +use Interop\Amqp\Impl\AmqpTopic; +use Interop\Queue\Context; +use Interop\Queue\Exception\InvalidDestinationException; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class AmqpContextTest extends TestCase { use ClassExtensionTrait; + use ReadAttributeTrait; - public function testShouldImplementPsrContextInterface() + public function testShouldImplementQueueInteropContextInterface() { $this->assertClassImplements(Context::class, AmqpContext::class); } - public function testCouldBeConstructedWithExtChannelAsFirstArgument() + public function testThrowIfNeitherCallbackNorExtChannelAsFirstArgument() { - new AmqpContext($this->createExtChannelMock()); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The extChannel argument must be either AMQPChannel or callable that return AMQPChannel.'); + + new AmqpContext(new \stdClass()); } public function testShouldReturnAmqpMessageOnCreateMessageCallWithoutArguments() @@ -60,27 +68,8 @@ public function testShouldCreateTopicWithGivenName() $this->assertInstanceOf(AmqpTopic::class, $topic); $this->assertSame('theName', $topic->getTopicName()); - $this->assertSame(\AMQP_NOPARAM, $topic->getFlags()); + $this->assertSame(AmqpTopic::FLAG_NOPARAM, $topic->getFlags()); $this->assertSame([], $topic->getArguments()); - $this->assertSame(null, $topic->getRoutingKey()); - } - - public function testShouldThrowIfNotAmqpTopicGivenOnDeleteTopicCall() - { - $context = new AmqpContext($this->createExtChannelMock()); - - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpTopic but got Enqueue\Transport\Null\NullTopic.'); - $context->deleteTopic(new NullTopic('aName')); - } - - public function testShouldThrowIfNotAmqpTopicGivenOnDeclareTopicCall() - { - $context = new AmqpContext($this->createExtChannelMock()); - - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpTopic but got Enqueue\Transport\Null\NullTopic.'); - $context->declareTopic(new NullTopic('aName')); } public function testShouldCreateQueueWithGivenName() @@ -91,28 +80,9 @@ public function testShouldCreateQueueWithGivenName() $this->assertInstanceOf(AmqpQueue::class, $queue); $this->assertSame('theName', $queue->getQueueName()); - $this->assertSame(\AMQP_NOPARAM, $queue->getFlags()); + $this->assertSame(AmqpQueue::FLAG_NOPARAM, $queue->getFlags()); $this->assertSame([], $queue->getArguments()); - $this->assertSame([], $queue->getBindArguments()); - $this->assertSame(null, $queue->getConsumerTag()); - } - - public function testShouldThrowIfNotAmqpQueueGivenOnDeleteQueueCall() - { - $context = new AmqpContext($this->createExtChannelMock()); - - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpQueue but got Enqueue\Transport\Null\NullQueue.'); - $context->deleteQueue(new NullQueue('aName')); - } - - public function testShouldThrowIfNotAmqpQueueGivenOnDeclareQueueCall() - { - $context = new AmqpContext($this->createExtChannelMock()); - - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpQueue but got Enqueue\Transport\Null\NullQueue.'); - $context->declareQueue(new NullQueue('aName')); + $this->assertNull($queue->getConsumerTag()); } public function testShouldReturnAmqpProducer() @@ -142,7 +112,7 @@ public function testShouldThrowIfNotAmqpQueueGivenOnCreateConsumerCall() $context = new AmqpContext($this->createExtChannelMock()); $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpQueue but got Enqueue\Transport\Null\NullQueue.'); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpQueue but got Enqueue\Null\NullQueue.'); $context->createConsumer(new NullQueue('aName')); } @@ -151,11 +121,11 @@ public function testShouldThrowIfNotAmqpTopicGivenOnCreateConsumerCall() $context = new AmqpContext($this->createExtChannelMock()); $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpTopic but got Enqueue\Transport\Null\NullTopic.'); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpTopic but got Enqueue\Null\NullTopic.'); $context->createConsumer(new NullTopic('aName')); } - public function shouldDoNothingIfConnectionAlreadyClosed() + public function testShouldDoNothingIfConnectionAlreadyClosed() { $extConnectionMock = $this->createExtConnectionMock(); $extConnectionMock @@ -256,26 +226,15 @@ public function testShouldClosePersistedConnection() $context->close(); } - public function testShouldThrowIfSourceNotAmqpTopicOnBindCall() + public function testShouldReturnExpectedSubscriptionConsumerInstance() { $context = new AmqpContext($this->createExtChannelMock()); - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpTopic but got Enqueue\Transport\Null\NullTopic.'); - $context->bind(new NullTopic('aName'), new AmqpQueue('aName')); - } - - public function testShouldThrowIfTargetNotAmqpQueueOnBindCall() - { - $context = new AmqpContext($this->createExtChannelMock()); - - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage('The destination must be an instance of Enqueue\AmqpExt\AmqpQueue but got Enqueue\Transport\Null\NullQueue.'); - $context->bind(new AmqpTopic('aName'), new NullQueue('aName')); + $this->assertInstanceOf(AmqpSubscriptionConsumer::class, $context->createSubscriptionConsumer()); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|\AMQPChannel + * @return MockObject|\AMQPChannel */ private function createExtChannelMock() { @@ -283,10 +242,13 @@ private function createExtChannelMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|\AMQPChannel + * @return MockObject|\AMQPChannel */ private function createExtConnectionMock() { - return $this->createMock(\AMQPConnection::class); + return $this->getMockBuilder(\AMQPConnection::class) + ->setMethods(['isPersistent', 'isConnected', 'pdisconnect', 'disconnect']) + ->getMock() + ; } } diff --git a/pkg/amqp-ext/Tests/AmqpMessageTest.php b/pkg/amqp-ext/Tests/AmqpMessageTest.php deleted file mode 100644 index 30fed0a4f..000000000 --- a/pkg/amqp-ext/Tests/AmqpMessageTest.php +++ /dev/null @@ -1,197 +0,0 @@ -assertClassImplements(Message::class, AmqpMessage::class); - } - - public function testCouldBeConstructedWithoutArguments() - { - $message = new AmqpMessage(); - - $this->assertNull($message->getBody()); - $this->assertSame([], $message->getProperties()); - $this->assertSame([], $message->getHeaders()); - } - - public function testCouldBeConstructedWithOptionalArguments() - { - $message = new AmqpMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); - - $this->assertSame('theBody', $message->getBody()); - $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); - $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); - } - - public function testShouldSetRedeliveredToFalseInConstructor() - { - $message = new AmqpMessage(); - - $this->assertSame(false, $message->isRedelivered()); - } - - public function testShouldSetNoParamFlagInConstructor() - { - $message = new AmqpMessage(); - - $this->assertSame(\AMQP_NOPARAM, $message->getFlags()); - } - - public function testShouldReturnPreviouslySetBody() - { - $message = new AmqpMessage(); - - $message->setBody('theBody'); - - $this->assertSame('theBody', $message->getBody()); - } - - public function testShouldReturnPreviouslySetProperties() - { - $message = new AmqpMessage(); - - $message->setProperties(['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $message->getProperties()); - } - - public function testShouldReturnPreviouslySetProperty() - { - $message = new AmqpMessage(null, ['foo' => 'fooVal']); - - $message->setProperty('bar', 'barVal'); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $message->getProperties()); - } - - public function testShouldReturnSinglePreviouslySetProperty() - { - $message = new AmqpMessage(); - - $this->assertSame(null, $message->getProperty('bar')); - $this->assertSame('default', $message->getProperty('bar', 'default')); - - $message->setProperty('bar', 'barVal'); - $this->assertSame('barVal', $message->getProperty('bar')); - } - - public function testShouldReturnPreviouslySetHeaders() - { - $message = new AmqpMessage(); - - $message->setHeaders(['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $message->getHeaders()); - } - - public function testShouldReturnPreviouslySetHeader() - { - $message = new AmqpMessage(null, [], ['foo' => 'fooVal']); - - $message->setHeader('bar', 'barVal'); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $message->getHeaders()); - } - - public function testShouldReturnSinglePreviouslySetHeader() - { - $message = new AmqpMessage(); - - $this->assertSame(null, $message->getHeader('bar')); - $this->assertSame('default', $message->getHeader('bar', 'default')); - - $message->setHeader('bar', 'barVal'); - $this->assertSame('barVal', $message->getHeader('bar')); - } - - public function testShouldReturnPreviouslySetRedelivered() - { - $message = new AmqpMessage(); - - $message->setRedelivered(true); - $this->assertSame(true, $message->isRedelivered()); - - $message->setRedelivered(false); - $this->assertSame(false, $message->isRedelivered()); - } - - public function testShouldReturnPreviouslySetCorrelationId() - { - $message = new AmqpMessage(); - $message->setCorrelationId('theCorrelationId'); - - $this->assertSame('theCorrelationId', $message->getCorrelationId()); - $this->assertSame(['correlation_id' => 'theCorrelationId'], $message->getHeaders()); - } - - public function testShouldReturnPreviouslySetMessageId() - { - $message = new AmqpMessage(); - $message->setMessageId('theMessageId'); - - $this->assertSame('theMessageId', $message->getMessageId()); - $this->assertSame(['message_id' => 'theMessageId'], $message->getHeaders()); - } - - public function testShouldReturnPreviouslySetTimestamp() - { - $message = new AmqpMessage(); - $message->setTimestamp('theTimestamp'); - - $this->assertSame('theTimestamp', $message->getTimestamp()); - $this->assertSame(['timestamp' => 'theTimestamp'], $message->getHeaders()); - } - - public function testShouldReturnPreviouslySetReplyTo() - { - $message = new AmqpMessage(); - $message->setReplyTo('theReply'); - - $this->assertSame('theReply', $message->getReplyTo()); - $this->assertSame(['reply_to' => 'theReply'], $message->getHeaders()); - } - - public function testShouldReturnPreviouslySetDeliveryTag() - { - $message = new AmqpMessage(); - - $message->setDeliveryTag('theDeliveryTag'); - - $this->assertSame('theDeliveryTag', $message->getDeliveryTag()); - } - - public function testShouldAllowAddFlags() - { - $message = new AmqpMessage(); - - $message->addFlag(AMQP_DURABLE); - $message->addFlag(AMQP_PASSIVE); - - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $message->getFlags()); - } - - public function testShouldClearPreviouslySetFlags() - { - $message = new AmqpMessage(); - - $message->addFlag(AMQP_DURABLE); - $message->addFlag(AMQP_PASSIVE); - - //guard - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $message->getFlags()); - - $message->clearFlags(); - - $this->assertSame(AMQP_NOPARAM, $message->getFlags()); - } -} diff --git a/pkg/amqp-ext/Tests/AmqpProducerTest.php b/pkg/amqp-ext/Tests/AmqpProducerTest.php new file mode 100644 index 000000000..30c2e99ef --- /dev/null +++ b/pkg/amqp-ext/Tests/AmqpProducerTest.php @@ -0,0 +1,18 @@ +assertClassImplements(Producer::class, AmqpProducer::class); + } +} diff --git a/pkg/amqp-ext/Tests/AmqpQueueTest.php b/pkg/amqp-ext/Tests/AmqpQueueTest.php deleted file mode 100644 index 56e234764..000000000 --- a/pkg/amqp-ext/Tests/AmqpQueueTest.php +++ /dev/null @@ -1,102 +0,0 @@ -assertClassImplements(Queue::class, AmqpQueue::class); - } - - public function testCouldBeConstructedWithQueueNameArgument() - { - new AmqpQueue('aName'); - } - - public function testShouldReturnQueueNameSetInConstructor() - { - $queue = new AmqpQueue('theName'); - - $this->assertSame('theName', $queue->getQueueName()); - } - - public function testShouldReturnPreviouslySetQueueName() - { - $queue = new AmqpQueue('aName'); - - $queue->setQueueName('theAnotherQueueName'); - - $this->assertSame('theAnotherQueueName', $queue->getQueueName()); - } - - public function testShouldSetEmptyArrayAsArgumentsInConstructor() - { - $queue = new AmqpQueue('aName'); - - $this->assertSame([], $queue->getArguments()); - } - - public function testShouldSetEmptyArrayAsBindArgumentsInConstructor() - { - $queue = new AmqpQueue('aName'); - - $this->assertSame([], $queue->getBindArguments()); - } - - public function testShouldSetNoParamFlagInConstructor() - { - $queue = new AmqpQueue('aName'); - - $this->assertSame(AMQP_NOPARAM, $queue->getFlags()); - } - - public function testShouldAllowAddFlags() - { - $queue = new AmqpQueue('aName'); - - $queue->addFlag(AMQP_DURABLE); - $queue->addFlag(AMQP_PASSIVE); - - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $queue->getFlags()); - } - - public function testShouldClearPreviouslySetFlags() - { - $queue = new AmqpQueue('aName'); - - $queue->addFlag(AMQP_DURABLE); - $queue->addFlag(AMQP_PASSIVE); - - //guard - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $queue->getFlags()); - - $queue->clearFlags(); - - $this->assertSame(AMQP_NOPARAM, $queue->getFlags()); - } - - public function testShouldAllowGetPreviouslySetArguments() - { - $queue = new AmqpQueue('aName'); - - $queue->setArguments(['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $queue->getArguments()); - } - - public function testShouldAllowGetPreviouslySetBindArguments() - { - $queue = new AmqpQueue('aName'); - - $queue->setBindArguments(['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $queue->getBindArguments()); - } -} diff --git a/pkg/amqp-ext/Tests/AmqpSubscriptionConsumerTest.php b/pkg/amqp-ext/Tests/AmqpSubscriptionConsumerTest.php new file mode 100644 index 000000000..d71ddd776 --- /dev/null +++ b/pkg/amqp-ext/Tests/AmqpSubscriptionConsumerTest.php @@ -0,0 +1,27 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + /** + * @return AmqpContext|MockObject + */ + private function createAmqpContextMock() + { + return $this->createMock(AmqpContext::class); + } +} diff --git a/pkg/amqp-ext/Tests/AmqpTopicTest.php b/pkg/amqp-ext/Tests/AmqpTopicTest.php deleted file mode 100644 index d132eef3c..000000000 --- a/pkg/amqp-ext/Tests/AmqpTopicTest.php +++ /dev/null @@ -1,114 +0,0 @@ -assertClassImplements(Topic::class, AmqpTopic::class); - } - - public function testCouldBeConstructedWithTopicNameAsArgument() - { - new AmqpTopic('aName'); - } - - public function testShouldReturnTopicNameSetInConstructor() - { - $topic = new AmqpTopic('theName'); - - $this->assertSame('theName', $topic->getTopicName()); - } - - public function testShouldReturnPreviouslySetTopicName() - { - $topic = new AmqpTopic('aName'); - - $topic->setTopicName('theAnotherTopicName'); - - $this->assertSame('theAnotherTopicName', $topic->getTopicName()); - } - - public function testShouldSetEmptyArrayAsArgumentsInConstructor() - { - $topic = new AmqpTopic('aName'); - - $this->assertSame([], $topic->getArguments()); - } - - public function testShouldSetDirectTypeInConstructor() - { - $topic = new AmqpTopic('aName'); - - $this->assertSame(\AMQP_EX_TYPE_DIRECT, $topic->getType()); - } - - public function testShouldSetNoParamFlagInConstructor() - { - $topic = new AmqpTopic('aName'); - - $this->assertSame(AMQP_NOPARAM, $topic->getFlags()); - } - - public function testShouldAllowAddFlags() - { - $topic = new AmqpTopic('aName'); - - $topic->addFlag(AMQP_DURABLE); - $topic->addFlag(AMQP_PASSIVE); - - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $topic->getFlags()); - } - - public function testShouldClearPreviouslySetFlags() - { - $topic = new AmqpTopic('aName'); - - $topic->addFlag(AMQP_DURABLE); - $topic->addFlag(AMQP_PASSIVE); - - //guard - $this->assertSame(AMQP_DURABLE | AMQP_PASSIVE, $topic->getFlags()); - - $topic->clearFlags(); - - $this->assertSame(AMQP_NOPARAM, $topic->getFlags()); - } - - public function testShouldAllowGetPreviouslySetArguments() - { - $topic = new AmqpTopic('aName'); - - $topic->setArguments(['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame(['foo' => 'fooVal', 'bar' => 'barVal'], $topic->getArguments()); - } - - public function testShouldAllowGetPreviouslySetType() - { - $topic = new AmqpTopic('aName'); - - $topic->setType(\AMQP_EX_TYPE_FANOUT); - - $this->assertSame(\AMQP_EX_TYPE_FANOUT, $topic->getType()); - } - - public function testShouldAllowGetPreviouslySetRoutingKey() - { - $topic = new AmqpTopic('aName'); - - //guard - $this->assertSame(null, $topic->getRoutingKey()); - - $topic->setRoutingKey('theRoutingKey'); - - $this->assertSame('theRoutingKey', $topic->getRoutingKey()); - } -} diff --git a/pkg/amqp-ext/Tests/Client/AmqpDriverTest.php b/pkg/amqp-ext/Tests/Client/AmqpDriverTest.php deleted file mode 100644 index 60955475a..000000000 --- a/pkg/amqp-ext/Tests/Client/AmqpDriverTest.php +++ /dev/null @@ -1,413 +0,0 @@ -assertClassImplements(DriverInterface::class, AmqpDriver::class); - } - - public function testCouldBeConstructedWithRequiredArguments() - { - new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - } - - public function testShouldReturnConfigObject() - { - $config = new Config('', '', '', '', '', ''); - - $driver = new AmqpDriver($this->createPsrContextMock(), $config, $this->createQueueMetaRegistryMock()); - - $this->assertSame($config, $driver->getConfig()); - } - - public function testShouldCreateAndReturnQueueInstance() - { - $expectedQueue = new AmqpQueue('queue-name'); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->with('name') - ->will($this->returnValue($expectedQueue)) - ; - - $driver = new AmqpDriver($context, new Config('', '', '', '', '', ''), $this->createQueueMetaRegistryMock()); - - $queue = $driver->createQueue('name'); - - $this->assertSame($expectedQueue, $queue); - $this->assertSame('queue-name', $queue->getQueueName()); - $this->assertSame(['x-max-priority' => 4], $queue->getArguments()); - $this->assertSame(2, $queue->getFlags()); - $this->assertNull($queue->getConsumerTag()); - $this->assertSame([], $queue->getBindArguments()); - } - - public function testShouldConvertTransportMessageToClientMessage() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setBody('body'); - $transportMessage->setHeaders(['hkey' => 'hval']); - $transportMessage->setProperties(['key' => 'val']); - $transportMessage->setHeader('content_type', 'ContentType'); - $transportMessage->setHeader('expiration', '12345000'); - $transportMessage->setHeader('priority', 3); - $transportMessage->setMessageId('MessageId'); - $transportMessage->setTimestamp(1000); - - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $clientMessage = $driver->createClientMessage($transportMessage); - - $this->assertInstanceOf(Message::class, $clientMessage); - $this->assertSame('body', $clientMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content_type' => 'ContentType', - 'expiration' => '12345000', - 'priority' => 3, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - ], $clientMessage->getHeaders()); - $this->assertSame([ - 'key' => 'val', - ], $clientMessage->getProperties()); - $this->assertSame('MessageId', $clientMessage->getMessageId()); - $this->assertSame(12345, $clientMessage->getExpire()); - $this->assertSame('ContentType', $clientMessage->getContentType()); - $this->assertSame(1000, $clientMessage->getTimestamp()); - $this->assertSame(MessagePriority::HIGH, $clientMessage->getPriority()); - } - - public function testShouldThrowExceptionIfExpirationIsNotNumeric() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setHeader('expiration', 'is-not-numeric'); - - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('expiration header is not numeric. "is-not-numeric"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertTransportPriorityToClientPriority() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setHeader('priority', 'unknown'); - - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Cant convert transport priority to client: "unknown"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertClientPriorityToTransportPriority() - { - $clientMessage = new Message(); - $clientMessage->setPriority('unknown'); - - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Given priority could not be converted to client\'s one. Got: unknown'); - - $driver->createTransportMessage($clientMessage); - } - - public function testShouldConvertClientMessageToTransportMessage() - { - $clientMessage = new Message(); - $clientMessage->setBody('body'); - $clientMessage->setHeaders(['hkey' => 'hval']); - $clientMessage->setProperties(['key' => 'val']); - $clientMessage->setContentType('ContentType'); - $clientMessage->setExpire(123); - $clientMessage->setPriority(MessagePriority::VERY_HIGH); - $clientMessage->setMessageId('MessageId'); - $clientMessage->setTimestamp(1000); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new AmqpMessage()) - ; - - $driver = new AmqpDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $transportMessage = $driver->createTransportMessage($clientMessage); - - $this->assertInstanceOf(AmqpMessage::class, $transportMessage); - $this->assertSame('body', $transportMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content_type' => 'ContentType', - 'expiration' => '123000', - 'priority' => 4, - 'delivery_mode' => 2, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - ], $transportMessage->getHeaders()); - $this->assertSame([ - 'key' => 'val', - ], $transportMessage->getProperties()); - $this->assertSame('MessageId', $transportMessage->getMessageId()); - $this->assertSame(1000, $transportMessage->getTimestamp()); - } - - public function testShouldSendMessageToRouter() - { - $topic = new AmqpTopic(''); - $transportMessage = new AmqpMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($topic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($topic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new AmqpDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_TOPIC_NAME, 'topic'); - - $driver->sendToRouter($message); - } - - public function testShouldThrowExceptionIfTopicParameterIsNotSet() - { - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name parameter is required but is not set'); - - $driver->sendToRouter(new Message()); - } - - public function testShouldSendMessageToProcessor() - { - $queue = new AmqpQueue(''); - $transportMessage = new AmqpMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new AmqpDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - - $driver->sendToProcessor($message); - } - - public function testShouldThrowExceptionIfProcessorNameParameterIsNotSet() - { - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Processor name parameter is required but is not set'); - - $driver->sendToProcessor(new Message()); - } - - public function testShouldThrowExceptionIfProcessorQueueNameParameterIsNotSet() - { - $driver = new AmqpDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Queue name parameter is required but is not set'); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - - $driver->sendToProcessor($message); - } - - public function testShouldSetupBroker() - { - $routerTopic = new AmqpTopic(''); - $routerQueue = new AmqpQueue(''); - - $processorQueue = new AmqpQueue(''); - - $context = $this->createPsrContextMock(); - // setup router - $context - ->expects($this->at(0)) - ->method('createTopic') - ->willReturn($routerTopic) - ; - $context - ->expects($this->at(1)) - ->method('createQueue') - ->willReturn($routerQueue) - ; - $context - ->expects($this->at(2)) - ->method('declareTopic') - ->with($this->identicalTo($routerTopic)) - ; - $context - ->expects($this->at(3)) - ->method('declareQueue') - ->with($this->identicalTo($routerQueue)) - ; - $context - ->expects($this->at(4)) - ->method('bind') - ->with($this->identicalTo($routerTopic), $this->identicalTo($routerQueue)) - ; - // setup processor queue - $context - ->expects($this->at(5)) - ->method('createQueue') - ->willReturn($processorQueue) - ; - $context - ->expects($this->at(6)) - ->method('declareQueue') - ->with($this->identicalTo($processorQueue)) - ; - - $meta = new QueueMetaRegistry(new Config('', '', '', '', '', ''), [ - 'default' => [], - ], 'default'); - - $driver = new AmqpDriver( - $context, - new Config('', '', '', '', '', ''), - $meta - ); - - $driver->setupBroker(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|AmqpContext - */ - private function createPsrContextMock() - { - return $this->createMock(AmqpContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Producer - */ - private function createPsrProducerMock() - { - return $this->createMock(Producer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueMetaRegistry - */ - private function createQueueMetaRegistryMock() - { - return $this->createMock(QueueMetaRegistry::class); - } -} diff --git a/pkg/amqp-ext/Tests/Client/RabbitMqDriverTest.php b/pkg/amqp-ext/Tests/Client/RabbitMqDriverTest.php deleted file mode 100644 index db88f30be..000000000 --- a/pkg/amqp-ext/Tests/Client/RabbitMqDriverTest.php +++ /dev/null @@ -1,581 +0,0 @@ -assertClassImplements(DriverInterface::class, RabbitMqDriver::class); - } - - public function testShouldExtendsAmqpDriverClass() - { - $this->assertClassExtends(AmqpDriver::class, RabbitMqDriver::class); - } - - public function testCouldBeConstructedWithRequiredArguments() - { - new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - } - - public function testShouldReturnConfigObject() - { - $config = new Config('', '', '', '', '', ''); - - $driver = new RabbitMqDriver($this->createPsrContextMock(), $config, $this->createQueueMetaRegistryMock()); - - $this->assertSame($config, $driver->getConfig()); - } - - public function testShouldCreateAndReturnQueueInstance() - { - $expectedQueue = new AmqpQueue('queue-name'); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->with('name') - ->will($this->returnValue($expectedQueue)) - ; - - $driver = new RabbitMqDriver($context, new Config('', '', '', '', '', ''), $this->createQueueMetaRegistryMock()); - - $queue = $driver->createQueue('name'); - - $this->assertSame($expectedQueue, $queue); - $this->assertSame('queue-name', $queue->getQueueName()); - $this->assertSame(['x-max-priority' => 4], $queue->getArguments()); - $this->assertSame(2, $queue->getFlags()); - $this->assertNull($queue->getConsumerTag()); - $this->assertSame([], $queue->getBindArguments()); - } - - public function testShouldConvertTransportMessageToClientMessage() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setBody('body'); - $transportMessage->setHeaders(['hkey' => 'hval']); - $transportMessage->setProperties(['key' => 'val']); - $transportMessage->setProperty('x-delay', '5678000'); - $transportMessage->setHeader('content_type', 'ContentType'); - $transportMessage->setHeader('expiration', '12345000'); - $transportMessage->setHeader('priority', 3); - $transportMessage->setMessageId('MessageId'); - $transportMessage->setTimestamp(1000); - - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]), - $this->createQueueMetaRegistryMock() - ); - - $clientMessage = $driver->createClientMessage($transportMessage); - - $this->assertInstanceOf(Message::class, $clientMessage); - $this->assertSame('body', $clientMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content_type' => 'ContentType', - 'expiration' => '12345000', - 'priority' => 3, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - ], $clientMessage->getHeaders()); - $this->assertSame([ - 'key' => 'val', - 'x-delay' => '5678000', - ], $clientMessage->getProperties()); - $this->assertSame('MessageId', $clientMessage->getMessageId()); - $this->assertSame(12345, $clientMessage->getExpire()); - $this->assertSame(5678, $clientMessage->getDelay()); - $this->assertSame('ContentType', $clientMessage->getContentType()); - $this->assertSame(1000, $clientMessage->getTimestamp()); - $this->assertSame(MessagePriority::HIGH, $clientMessage->getPriority()); - } - - public function testShouldThrowExceptionIfXDelayIsNotNumeric() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setProperty('x-delay', 'is-not-numeric'); - - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('x-delay header is not numeric. "is-not-numeric"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfExpirationIsNotNumeric() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setHeader('expiration', 'is-not-numeric'); - - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('expiration header is not numeric. "is-not-numeric"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertTransportPriorityToClientPriority() - { - $transportMessage = new AmqpMessage(); - $transportMessage->setHeader('priority', 'unknown'); - - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Cant convert transport priority to client: "unknown"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertClientPriorityToTransportPriority() - { - $clientMessage = new Message(); - $clientMessage->setPriority('unknown'); - - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Given priority could not be converted to client\'s one. Got: unknown'); - - $driver->createTransportMessage($clientMessage); - } - - public function testShouldConvertClientMessageToTransportMessage() - { - $clientMessage = new Message(); - $clientMessage->setBody('body'); - $clientMessage->setHeaders(['hkey' => 'hval']); - $clientMessage->setProperties(['key' => 'val']); - $clientMessage->setContentType('ContentType'); - $clientMessage->setExpire(123); - $clientMessage->setPriority(MessagePriority::VERY_HIGH); - $clientMessage->setDelay(432); - $clientMessage->setMessageId('MessageId'); - $clientMessage->setTimestamp(1000); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new AmqpMessage()) - ; - - $driver = new RabbitMqDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]), - $this->createQueueMetaRegistryMock() - ); - - $transportMessage = $driver->createTransportMessage($clientMessage); - - $this->assertInstanceOf(AmqpMessage::class, $transportMessage); - $this->assertSame('body', $transportMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content_type' => 'ContentType', - 'expiration' => '123000', - 'priority' => 4, - 'delivery_mode' => 2, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - ], $transportMessage->getHeaders()); - $this->assertSame([ - 'key' => 'val', - 'x-delay' => '432000', - ], $transportMessage->getProperties()); - $this->assertSame('MessageId', $transportMessage->getMessageId()); - $this->assertSame(1000, $transportMessage->getTimestamp()); - } - - public function testThrowIfDelayNotSupportedOnConvertClientMessageToTransportMessage() - { - $clientMessage = new Message(); - $clientMessage->setDelay(432); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new AmqpMessage()) - ; - - $driver = new RabbitMqDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => false]), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); - $driver->createTransportMessage($clientMessage); - } - - public function testShouldSendMessageToRouter() - { - $topic = new AmqpTopic(''); - $transportMessage = new AmqpMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($topic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($topic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_TOPIC_NAME, 'topic'); - - $driver->sendToRouter($message); - } - - public function testShouldThrowExceptionIfTopicParameterIsNotSet() - { - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name parameter is required but is not set'); - - $driver->sendToRouter(new Message()); - } - - public function testShouldSendMessageToProcessor() - { - $queue = new AmqpQueue(''); - $transportMessage = new AmqpMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - - $driver->sendToProcessor($message); - } - - public function testShouldSendMessageToDelayExchangeIfDelaySet() - { - $queue = new AmqpQueue(''); - $delayTopic = new AmqpTopic(''); - $transportMessage = new AmqpMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($delayTopic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($delayTopic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]), - $this->createQueueMetaRegistryMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - $message->setDelay(10); - - $driver->sendToProcessor($message); - } - - public function testShouldThrowExceptionIfProcessorNameParameterIsNotSet() - { - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Processor name parameter is required but is not set'); - - $driver->sendToProcessor(new Message()); - } - - public function testShouldThrowExceptionIfProcessorQueueNameParameterIsNotSet() - { - $driver = new RabbitMqDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Queue name parameter is required but is not set'); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - - $driver->sendToProcessor($message); - } - - public function testShouldSetupBrokerWhenDelayPluginNotInstalled() - { - $routerTopic = new AmqpTopic(''); - $routerQueue = new AmqpQueue(''); - - $processorQueue = new AmqpQueue(''); - $delayTopic = new AmqpTopic(''); - - $context = $this->createPsrContextMock(); - // setup router - $context - ->expects($this->at(0)) - ->method('createTopic') - ->willReturn($routerTopic) - ; - $context - ->expects($this->at(1)) - ->method('createQueue') - ->willReturn($routerQueue) - ; - $context - ->expects($this->at(2)) - ->method('declareTopic') - ->with($this->identicalTo($routerTopic)) - ; - $context - ->expects($this->at(3)) - ->method('declareQueue') - ->with($this->identicalTo($routerQueue)) - ; - $context - ->expects($this->at(4)) - ->method('bind') - ->with($this->identicalTo($routerTopic), $this->identicalTo($routerQueue)) - ; - // setup processor queue - $context - ->expects($this->at(5)) - ->method('createQueue') - ->willReturn($processorQueue) - ; - - $config = new Config('', '', '', '', '', '', ['delay_plugin_installed' => false]); - - $meta = new QueueMetaRegistry($config, ['default' => []]); - - $driver = new RabbitMqDriver($context, $config, $meta); - - $driver->setupBroker(); - } - - public function testShouldSetupBroker() - { - $routerTopic = new AmqpTopic(''); - $routerQueue = new AmqpQueue(''); - - $processorQueue = new AmqpQueue(''); - $delayTopic = new AmqpTopic(''); - - $context = $this->createPsrContextMock(); - // setup router - $context - ->expects($this->at(0)) - ->method('createTopic') - ->willReturn($routerTopic) - ; - $context - ->expects($this->at(1)) - ->method('createQueue') - ->willReturn($routerQueue) - ; - $context - ->expects($this->at(2)) - ->method('declareTopic') - ->with($this->identicalTo($routerTopic)) - ; - $context - ->expects($this->at(3)) - ->method('declareQueue') - ->with($this->identicalTo($routerQueue)) - ; - $context - ->expects($this->at(4)) - ->method('bind') - ->with($this->identicalTo($routerTopic), $this->identicalTo($routerQueue)) - ; - // setup processor queue - $context - ->expects($this->at(5)) - ->method('createQueue') - ->willReturn($processorQueue) - ; - $context - ->expects($this->at(6)) - ->method('declareQueue') - ->with($this->identicalTo($processorQueue)) - ; - $context - ->expects($this->at(7)) - ->method('createQueue') - ->willReturn($processorQueue) - ; - $context - ->expects($this->at(8)) - ->method('createTopic') - ->willReturn($delayTopic) - ; - $context - ->expects($this->at(9)) - ->method('declareTopic') - ->with($this->identicalTo($delayTopic)) - ; - - $context - ->expects($this->at(10)) - ->method('bind') - ->with($this->identicalTo($delayTopic), $this->identicalTo($processorQueue)) - ; - - $config = new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]); - - $meta = new QueueMetaRegistry($config, ['default' => []]); - - $driver = new RabbitMqDriver($context, $config, $meta); - - $driver->setupBroker(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|AmqpContext - */ - private function createPsrContextMock() - { - return $this->createMock(AmqpContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Producer - */ - private function createPsrProducerMock() - { - return $this->createMock(Producer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueMetaRegistry - */ - private function createQueueMetaRegistryMock() - { - return $this->createMock(QueueMetaRegistry::class); - } -} diff --git a/pkg/amqp-ext/Tests/Functional/AmqpCommonUseCasesTest.php b/pkg/amqp-ext/Tests/Functional/AmqpCommonUseCasesTest.php index be0ef4fef..ee53ad90b 100644 --- a/pkg/amqp-ext/Tests/Functional/AmqpCommonUseCasesTest.php +++ b/pkg/amqp-ext/Tests/Functional/AmqpCommonUseCasesTest.php @@ -3,24 +3,26 @@ namespace Enqueue\AmqpExt\Tests\Functional; use Enqueue\AmqpExt\AmqpContext; -use Enqueue\AmqpExt\AmqpMessage; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqAmqpExtension; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Interop\Amqp\Impl\AmqpBind; +use Interop\Amqp\Impl\AmqpMessage; +use PHPUnit\Framework\TestCase; /** * @group functional */ -class AmqpCommonUseCasesTest extends \PHPUnit_Framework_TestCase +class AmqpCommonUseCasesTest extends TestCase { + use RabbitManagementExtensionTrait; use RabbitmqAmqpExtension; - use RabbitmqManagmentExtensionTrait; /** * @var AmqpContext */ private $amqpContext; - public function setUp() + protected function setUp(): void { $this->amqpContext = $this->buildAmqpContext(); @@ -28,7 +30,7 @@ public function setUp() $this->removeExchange('amqp_ext.test_exchange'); } - public function tearDown() + protected function tearDown(): void { $this->amqpContext->close(); } @@ -41,7 +43,7 @@ public function testWaitsForTwoSecondsAndReturnNullOnReceive() $startAt = microtime(true); $consumer = $this->amqpContext->createConsumer($queue); - $message = $consumer->receive(2); + $message = $consumer->receive(2000); $endAt = microtime(true); @@ -83,7 +85,7 @@ public function testProduceAndReceiveOneMessageSentDirectlyToQueue() $producer->send($queue, $message); $consumer = $this->amqpContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $message); $consumer->acknowledge($message); @@ -110,12 +112,13 @@ public function testProduceAndReceiveOneMessageSentDirectlyToTemporaryQueue() $queue = $this->amqpContext->createTemporaryQueue(); $message = $this->amqpContext->createMessage(__METHOD__); + $message->setDeliveryTag(145); $producer = $this->amqpContext->createProducer(); $producer->send($queue, $message); $consumer = $this->amqpContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $message); $consumer->acknowledge($message); @@ -126,21 +129,22 @@ public function testProduceAndReceiveOneMessageSentDirectlyToTemporaryQueue() public function testProduceAndReceiveOneMessageSentDirectlyToTopic() { $topic = $this->amqpContext->createTopic('amqp_ext.test_exchange'); - $topic->setType(AMQP_EX_TYPE_FANOUT); + $topic->setType(\AMQP_EX_TYPE_FANOUT); $this->amqpContext->declareTopic($topic); $queue = $this->amqpContext->createQueue('amqp_ext.test'); $this->amqpContext->declareQueue($queue); - $this->amqpContext->bind($topic, $queue); + $this->amqpContext->bind(new AmqpBind($topic, $queue)); $message = $this->amqpContext->createMessage(__METHOD__); + $message->setDeliveryTag(145); $producer = $this->amqpContext->createProducer(); $producer->send($topic, $message); $consumer = $this->amqpContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $message); $consumer->acknowledge($message); @@ -151,23 +155,68 @@ public function testProduceAndReceiveOneMessageSentDirectlyToTopic() public function testConsumerReceiveMessageFromTopicDirectly() { $topic = $this->amqpContext->createTopic('amqp_ext.test_exchange'); - $topic->setType(AMQP_EX_TYPE_FANOUT); + $topic->setType(\AMQP_EX_TYPE_FANOUT); $this->amqpContext->declareTopic($topic); $consumer = $this->amqpContext->createConsumer($topic); - //guard - $this->assertNull($consumer->receive(1)); + // guard + $this->assertNull($consumer->receive(1000)); $message = $this->amqpContext->createMessage(__METHOD__); + $message->setDeliveryTag(145); $producer = $this->amqpContext->createProducer(); $producer->send($topic, $message); - $actualMessage = $consumer->receive(1); + $actualMessage = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $actualMessage); $consumer->acknowledge($message); $this->assertEquals(__METHOD__, $message->getBody()); } + + public function testConsumerReceiveMessageWithZeroTimeout() + { + $topic = $this->amqpContext->createTopic('amqp_ext.test_exchange'); + $topic->setType(\AMQP_EX_TYPE_FANOUT); + + $this->amqpContext->declareTopic($topic); + + $consumer = $this->amqpContext->createConsumer($topic); + // guard + $this->assertNull($consumer->receive(1000)); + + $message = $this->amqpContext->createMessage(__METHOD__); + $message->setDeliveryTag(145); + + $producer = $this->amqpContext->createProducer(); + $producer->send($topic, $message); + usleep(100); + $actualMessage = $consumer->receive(0); + + $this->assertInstanceOf(AmqpMessage::class, $actualMessage); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + } + + public function testPurgeMessagesFromQueue() + { + $queue = $this->amqpContext->createQueue('amqp_ext.test'); + $this->amqpContext->declareQueue($queue); + + $consumer = $this->amqpContext->createConsumer($queue); + + $message = $this->amqpContext->createMessage(__METHOD__); + $message->setDeliveryTag(145); + + $producer = $this->amqpContext->createProducer(); + $producer->send($queue, $message); + $producer->send($queue, $message); + + $this->amqpContext->purgeQueue($queue); + + $this->assertNull($consumer->receive(1)); + } } diff --git a/pkg/amqp-ext/Tests/Functional/AmqpConsumptionUseCasesTest.php b/pkg/amqp-ext/Tests/Functional/AmqpConsumptionUseCasesTest.php index 76abbfce9..51d5a7c54 100644 --- a/pkg/amqp-ext/Tests/Functional/AmqpConsumptionUseCasesTest.php +++ b/pkg/amqp-ext/Tests/Functional/AmqpConsumptionUseCasesTest.php @@ -9,33 +9,34 @@ use Enqueue\Consumption\Extension\ReplyExtension; use Enqueue\Consumption\QueueConsumer; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqAmqpExtension; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\Processor; +use PHPUnit\Framework\TestCase; /** * @group functional */ -class AmqpConsumptionUseCasesTest extends \PHPUnit_Framework_TestCase +class AmqpConsumptionUseCasesTest extends TestCase { + use RabbitManagementExtensionTrait; use RabbitmqAmqpExtension; - use RabbitmqManagmentExtensionTrait; /** * @var AmqpContext */ private $amqpContext; - public function setUp() + protected function setUp(): void { $this->amqpContext = $this->buildAmqpContext(); $this->removeQueue('amqp_ext.test'); } - public function tearDown() + protected function tearDown(): void { $this->amqpContext->close(); } @@ -101,9 +102,9 @@ public function testConsumeOneMessageAndSendReplyExit() class StubProcessor implements Processor { - public $result = Result::ACK; + public $result = self::ACK; - /** @var \Enqueue\Psr\Message */ + /** @var Message */ public $lastProcessedMessage; public function process(Message $message, Context $context) diff --git a/pkg/amqp-ext/Tests/Functional/AmqpRpcUseCasesTest.php b/pkg/amqp-ext/Tests/Functional/AmqpRpcUseCasesTest.php index 3085e858f..66ae69241 100644 --- a/pkg/amqp-ext/Tests/Functional/AmqpRpcUseCasesTest.php +++ b/pkg/amqp-ext/Tests/Functional/AmqpRpcUseCasesTest.php @@ -3,26 +3,27 @@ namespace Enqueue\AmqpExt\Tests\Functional; use Enqueue\AmqpExt\AmqpContext; -use Enqueue\AmqpExt\AmqpMessage; use Enqueue\Rpc\Promise; use Enqueue\Rpc\RpcClient; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqAmqpExtension; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Interop\Amqp\Impl\AmqpMessage; +use PHPUnit\Framework\TestCase; /** * @group functional */ -class AmqpRpcUseCasesTest extends \PHPUnit_Framework_TestCase +class AmqpRpcUseCasesTest extends TestCase { + use RabbitManagementExtensionTrait; use RabbitmqAmqpExtension; - use RabbitmqManagmentExtensionTrait; /** * @var AmqpContext */ private $amqpContext; - public function setUp() + protected function setUp(): void { $this->amqpContext = $this->buildAmqpContext(); @@ -30,7 +31,7 @@ public function setUp() $this->removeQueue('rpc.reply_test'); } - public function tearDown() + protected function tearDown(): void { $this->amqpContext->close(); } @@ -52,7 +53,7 @@ public function testDoAsyncRpcCallWithCustomReplyQueue() $this->assertInstanceOf(Promise::class, $promise); $consumer = $this->amqpContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $message); $this->assertNotNull($message->getReplyTo()); $this->assertNotNull($message->getCorrelationId()); @@ -64,7 +65,7 @@ public function testDoAsyncRpcCallWithCustomReplyQueue() $this->amqpContext->createProducer()->send($replyQueue, $replyMessage); - $actualReplyMessage = $promise->getMessage(); + $actualReplyMessage = $promise->receive(); $this->assertInstanceOf(AmqpMessage::class, $actualReplyMessage); } @@ -81,7 +82,7 @@ public function testDoAsyncRecCallWithCastInternallyCreatedTemporaryReplyQueue() $this->assertInstanceOf(Promise::class, $promise); $consumer = $this->amqpContext->createConsumer($queue); - $receivedMessage = $consumer->receive(1); + $receivedMessage = $consumer->receive(1000); $this->assertInstanceOf(AmqpMessage::class, $receivedMessage); $this->assertNotNull($receivedMessage->getReplyTo()); @@ -94,7 +95,7 @@ public function testDoAsyncRecCallWithCastInternallyCreatedTemporaryReplyQueue() $this->amqpContext->createProducer()->send($replyQueue, $replyMessage); - $actualReplyMessage = $promise->getMessage(); + $actualReplyMessage = $promise->receive(); $this->assertInstanceOf(AmqpMessage::class, $actualReplyMessage); } } diff --git a/pkg/amqp-ext/Tests/Spec/AmqpMessageTest.php b/pkg/amqp-ext/Tests/Spec/AmqpMessageTest.php new file mode 100644 index 000000000..73fc4ad14 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpMessageTest.php @@ -0,0 +1,14 @@ +createContext()->createProducer(); + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php new file mode 100644 index 000000000..f79f7e635 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php @@ -0,0 +1,36 @@ +setDelayStrategy(new RabbitMqDelayPluginDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php new file mode 100644 index 000000000..71da671f4 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php @@ -0,0 +1,36 @@ +setDelayStrategy(new RabbitMqDlxDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..40edcd865 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $queue->setArguments(['x-max-priority' => 10]); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..6654107c9 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php new file mode 100644 index 000000000..5ecc00046 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..cb45dc5a5 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..fb8e62750 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..a0d93c38b --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..f9867d1b1 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..058606b51 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..b8ac82403 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..0aa03cbd0 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php @@ -0,0 +1,47 @@ +assertNotEmpty($baseDir); + + $certDir = $baseDir.'/var/rabbitmq_certificates'; + $this->assertDirectoryExists($certDir); + + $factory = new AmqpConnectionFactory([ + 'dsn' => getenv('AMQPS_DSN'), + 'ssl_verify' => false, + 'ssl_cacert' => $certDir.'/cacert.pem', + 'ssl_cert' => $certDir.'/cert.pem', + 'ssl_key' => $certDir.'/key.pem', + ]); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php new file mode 100644 index 000000000..173247262 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..c069acefd --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..c3341c937 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,50 @@ +subscriptionConsumer) { + $this->subscriptionConsumer->unsubscribeAll(); + } + + parent::tearDown(); + } + + /** + * @return AmqpContext + */ + protected function createContext() + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + + $context = $factory->createContext(); + $context->setQos(0, 1, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php new file mode 100644 index 000000000..58182946a --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php new file mode 100644 index 000000000..f528dbc13 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php @@ -0,0 +1,25 @@ +markTestIncomplete('Seg fault.'); + } + + protected function createContext(): AmqpContext + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + + return $factory->createContext(); + } +} diff --git a/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..e017bb603 --- /dev/null +++ b/pkg/amqp-ext/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-ext/Tests/Symfony/AmqpTransportFactoryTest.php b/pkg/amqp-ext/Tests/Symfony/AmqpTransportFactoryTest.php deleted file mode 100644 index 6dcf6f738..000000000 --- a/pkg/amqp-ext/Tests/Symfony/AmqpTransportFactoryTest.php +++ /dev/null @@ -1,108 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, AmqpTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new AmqpTransportFactory(); - - $this->assertEquals('amqp', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new AmqpTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new AmqpTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), []); - - $this->assertEquals([ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - ], $config); - } - - public function testShouldCreateContext() - { - $container = new ContainerBuilder(); - - $transport = new AmqpTransportFactory(); - - $serviceId = $transport->createContext($container, [ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - ]); - - $this->assertEquals('enqueue.transport.amqp.context', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $context = $container->getDefinition('enqueue.transport.amqp.context'); - $this->assertInstanceOf(Reference::class, $context->getFactory()[0]); - $this->assertEquals('enqueue.transport.amqp.connection_factory', (string) $context->getFactory()[0]); - $this->assertEquals('createContext', $context->getFactory()[1]); - - $this->assertTrue($container->hasDefinition('enqueue.transport.amqp.connection_factory')); - $factory = $container->getDefinition('enqueue.transport.amqp.connection_factory'); - $this->assertEquals(AmqpConnectionFactory::class, $factory->getClass()); - $this->assertSame([[ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - ]], $factory->getArguments()); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new AmqpTransportFactory(); - - $serviceId = $transport->createDriver($container, []); - - $this->assertEquals('enqueue.client.amqp.driver', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $driver = $container->getDefinition($serviceId); - $this->assertSame(AmqpDriver::class, $driver->getClass()); - } -} diff --git a/pkg/amqp-ext/Tests/Symfony/RabbitMqTransportFactoryTest.php b/pkg/amqp-ext/Tests/Symfony/RabbitMqTransportFactoryTest.php deleted file mode 100644 index 47258a765..000000000 --- a/pkg/amqp-ext/Tests/Symfony/RabbitMqTransportFactoryTest.php +++ /dev/null @@ -1,117 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, RabbitMqTransportFactory::class); - } - - public function testShouldExtendAmqpTransportFactoryClass() - { - $this->assertClassExtends(AmqpTransportFactory::class, RabbitMqTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new RabbitMqTransportFactory(); - - $this->assertEquals('rabbitmq', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new RabbitMqTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new RabbitMqTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), []); - - $this->assertEquals([ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - 'delay_plugin_installed' => false, - ], $config); - } - - public function testShouldCreateContext() - { - $container = new ContainerBuilder(); - - $transport = new RabbitMqTransportFactory(); - - $serviceId = $transport->createContext($container, [ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - 'delay_plugin_installed' => false, - ]); - - $this->assertEquals('enqueue.transport.rabbitmq.context', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $context = $container->getDefinition('enqueue.transport.rabbitmq.context'); - $this->assertInstanceOf(Reference::class, $context->getFactory()[0]); - $this->assertEquals('enqueue.transport.rabbitmq.connection_factory', (string) $context->getFactory()[0]); - $this->assertEquals('createContext', $context->getFactory()[1]); - - $this->assertTrue($container->hasDefinition('enqueue.transport.rabbitmq.connection_factory')); - $factory = $container->getDefinition('enqueue.transport.rabbitmq.connection_factory'); - $this->assertEquals(AmqpConnectionFactory::class, $factory->getClass()); - $this->assertSame([[ - 'host' => 'localhost', - 'port' => 5672, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'persisted' => false, - 'delay_plugin_installed' => false, - ]], $factory->getArguments()); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new RabbitMqTransportFactory(); - - $serviceId = $transport->createDriver($container, []); - - $this->assertEquals('enqueue.client.rabbitmq.driver', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $driver = $container->getDefinition($serviceId); - $this->assertSame(RabbitMqDriver::class, $driver->getClass()); - } -} diff --git a/pkg/amqp-ext/Tests/fix_composer_json.php b/pkg/amqp-ext/Tests/fix_composer_json.php new file mode 100644 index 000000000..01f73c95e --- /dev/null +++ b/pkg/amqp-ext/Tests/fix_composer_json.php @@ -0,0 +1,9 @@ +=5.6", - "ext-amqp": "^1.6", - "enqueue/psr-queue": "^0.2", - "psr/log": "^1" + "php": "^8.1", + "ext-amqp": "^1.9.3|^2.0.0", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "enqueue/amqp-tools": "^0.10" }, "require-dev": { - "phpunit/phpunit": "~5.4.0", - "enqueue/test": "^0.2", - "enqueue/enqueue": "^0.2", - "symfony/dependency-injection": "^2.8|^3", - "symfony/config": "^2.8|^3" + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2", + "empi89/php-amqp-stubs": "*@dev" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" }, "autoload": { "psr-4": { "Enqueue\\AmqpExt\\": "" }, @@ -29,13 +32,10 @@ "/Tests/" ] }, - "suggest": { - "enqueue/enqueue": "If you'd like to use advanced features like Client abstract layer or Symfony integration features" - }, "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } } } diff --git a/pkg/amqp-ext/examples/consume.php b/pkg/amqp-ext/examples/consume.php index 6ce9a7a0e..d510bf077 100644 --- a/pkg/amqp-ext/examples/consume.php +++ b/pkg/amqp-ext/examples/consume.php @@ -12,20 +12,12 @@ if ($autoload) { require_once $autoload; } else { - throw new \LogicException('Composer autoload was not found'); + throw new LogicException('Composer autoload was not found'); } use Enqueue\AmqpExt\AmqpConnectionFactory; -$config = [ - 'host' => getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__AMQP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), -]; - -$factory = new AmqpConnectionFactory($config); +$factory = new AmqpConnectionFactory(getenv('RABBITMQ_AMQP_DSN')); $context = $factory->createContext(); $queue = $context->createQueue('foo'); @@ -34,20 +26,17 @@ $queue = $context->createQueue('bar'); $barConsumer = $context->createConsumer($queue); -$consumer = $context->createConsumer($queue); - -$fooConsumer->receive(1); -$barConsumer->receive(1); - $consumers = [$fooConsumer, $barConsumer]; $consumer = $consumers[rand(0, 1)]; while (true) { if ($m = $consumer->receive(1)) { - $consumer = $consumers[rand(0, 1)]; + echo $m->getBody(), \PHP_EOL; $consumer->acknowledge($m); } + + $consumer = $consumers[rand(0, 1)]; } echo 'Done'."\n"; diff --git a/pkg/amqp-ext/examples/produce.php b/pkg/amqp-ext/examples/produce.php index 887575e39..dfc4374da 100644 --- a/pkg/amqp-ext/examples/produce.php +++ b/pkg/amqp-ext/examples/produce.php @@ -12,45 +12,40 @@ if ($autoload) { require_once $autoload; } else { - throw new \LogicException('Composer autoload was not found'); + throw new LogicException('Composer autoload was not found'); } use Enqueue\AmqpExt\AmqpConnectionFactory; +use Interop\Amqp\AmqpQueue; +use Interop\Amqp\AmqpTopic; +use Interop\Amqp\Impl\AmqpBind; -$config = [ - 'host' => getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__AMQP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), -]; - -$factory = new AmqpConnectionFactory($config); +$factory = new AmqpConnectionFactory(getenv('RABBITMQ_AMQP_DSN')); $context = $factory->createContext(); $topic = $context->createTopic('test.amqp.ext'); -$topic->addFlag(AMQP_DURABLE); -$topic->setType(AMQP_EX_TYPE_FANOUT); +$topic->addFlag(AmqpTopic::FLAG_DURABLE); +$topic->setType(AmqpTopic::TYPE_FANOUT); $topic->setArguments(['alternate-exchange' => 'foo']); $context->deleteTopic($topic); $context->declareTopic($topic); $fooQueue = $context->createQueue('foo'); -$fooQueue->addFlag(AMQP_DURABLE); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); $context->deleteQueue($fooQueue); $context->declareQueue($fooQueue); -$context->bind($topic, $fooQueue); +$context->bind(new AmqpBind($topic, $fooQueue)); $barQueue = $context->createQueue('bar'); -$barQueue->addFlag(AMQP_DURABLE); +$barQueue->addFlag(AmqpQueue::FLAG_DURABLE); $context->deleteQueue($barQueue); $context->declareQueue($barQueue); -$context->bind($topic, $barQueue); +$context->bind(new AmqpBind($topic, $barQueue)); $message = $context->createMessage('Hello Bar!'); diff --git a/pkg/amqp-ext/phpunit.xml.dist b/pkg/amqp-ext/phpunit.xml.dist index 4dca142e1..1e72c01a2 100644 --- a/pkg/amqp-ext/phpunit.xml.dist +++ b/pkg/amqp-ext/phpunit.xml.dist @@ -1,16 +1,11 @@ - + diff --git a/pkg/amqp-ext/travis/build-php-amqp-ext b/pkg/amqp-ext/travis/build-php-amqp-ext deleted file mode 100755 index edaca744a..000000000 --- a/pkg/amqp-ext/travis/build-php-amqp-ext +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -# build librabbitmq -cd $HOME -git clone git://github.com/alanxz/rabbitmq-c.git -cd $HOME/rabbitmq-c -git submodule init && git submodule update -autoreconf -i && ./configure --prefix=$HOME/rabbitmq-c && make && make install - -# build php-amqp extension -cd $HOME -git clone git://github.com/pdezwart/php-amqp.git -cd $HOME/php-amqp -phpize && ./configure --with-librabbitmq-dir=$HOME/rabbitmq-c && make && make install -echo "extension=amqp.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini diff --git a/pkg/amqp-lib/.gitattributes b/pkg/amqp-lib/.gitattributes new file mode 100644 index 000000000..a0efa0948 --- /dev/null +++ b/pkg/amqp-lib/.gitattributes @@ -0,0 +1,7 @@ +/examples export-ignore +/Tests export-ignore +/tutorial export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/amqp-lib/.github/workflows/ci.yml b/pkg/amqp-lib/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/amqp-lib/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/amqp-lib/.gitignore b/pkg/amqp-lib/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/amqp-lib/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/amqp-lib/AmqpConnectionFactory.php b/pkg/amqp-lib/AmqpConnectionFactory.php new file mode 100644 index 000000000..198e6874d --- /dev/null +++ b/pkg/amqp-lib/AmqpConnectionFactory.php @@ -0,0 +1,193 @@ +config = (new ConnectionConfig($config)) + ->addSupportedScheme('amqp+lib') + ->addSupportedScheme('amqps+lib') + ->addDefaultOption('stream', true) + ->addDefaultOption('insist', false) + ->addDefaultOption('login_method', 'AMQPLAIN') + ->addDefaultOption('login_response', null) + ->addDefaultOption('locale', 'en_US') + ->addDefaultOption('keepalive', false) + ->addDefaultOption('channel_rpc_timeout', 0.) + ->addDefaultOption('heartbeat_on_tick', true) + ->parse() + ; + + if (in_array('rabbitmq', $this->config->getSchemeExtensions(), true)) { + $this->setDelayStrategy(new RabbitMqDlxDelayStrategy()); + } + } + + /** + * @return AmqpContext + */ + public function createContext(): Context + { + $context = new AmqpContext($this->establishConnection(), $this->config->getConfig()); + $context->setDelayStrategy($this->delayStrategy); + + return $context; + } + + public function getConfig(): ConnectionConfig + { + return $this->config; + } + + private function establishConnection(): AbstractConnection + { + if (false == $this->connection) { + if ($this->config->getOption('stream')) { + if ($this->config->isSslOn()) { + $sslOptions = array_filter([ + 'cafile' => $this->config->getSslCaCert(), + 'local_cert' => $this->config->getSslCert(), + 'local_pk' => $this->config->getSslKey(), + 'verify_peer' => $this->config->isSslVerify(), + 'verify_peer_name' => $this->config->isSslVerify(), + 'passphrase' => $this->getConfig()->getSslPassPhrase(), + 'ciphers' => $this->config->getOption('ciphers', ''), + ], function ($value) { return '' !== $value; }); + + $con = new AMQPSSLConnection( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getUser(), + $this->config->getPass(), + $this->config->getVHost(), + $sslOptions, + [ + 'insist' => $this->config->getOption('insist'), + 'login_method' => $this->config->getOption('login_method'), + 'login_response' => $this->config->getOption('login_response'), + 'locale' => $this->config->getOption('locale'), + 'connection_timeout' => $this->config->getConnectionTimeout(), + 'read_write_timeout' => (int) round(min($this->config->getReadTimeout(), $this->config->getWriteTimeout())), + 'keepalive' => $this->config->getOption('keepalive'), + 'heartbeat' => (int) round($this->config->getHeartbeat()), + ] + ); + } elseif ($this->config->isLazy()) { + $con = new AMQPLazyConnection( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getUser(), + $this->config->getPass(), + $this->config->getVHost(), + $this->config->getOption('insist'), + $this->config->getOption('login_method'), + $this->config->getOption('login_response'), + $this->config->getOption('locale'), + $this->config->getConnectionTimeout(), + (int) round(min($this->config->getReadTimeout(), $this->config->getWriteTimeout())), + null, + $this->config->getOption('keepalive'), + (int) round($this->config->getHeartbeat()), + $this->config->getOption('channel_rpc_timeout') + ); + } else { + $con = new AMQPStreamConnection( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getUser(), + $this->config->getPass(), + $this->config->getVHost(), + $this->config->getOption('insist'), + $this->config->getOption('login_method'), + $this->config->getOption('login_response'), + $this->config->getOption('locale'), + $this->config->getConnectionTimeout(), + (int) round(min($this->config->getReadTimeout(), $this->config->getWriteTimeout())), + null, + $this->config->getOption('keepalive'), + (int) round($this->config->getHeartbeat()), + $this->config->getOption('channel_rpc_timeout') + ); + } + } else { + if ($this->config->isSslOn()) { + throw new \LogicException('The socket connection implementation does not support ssl connections.'); + } + + if ($this->config->isLazy()) { + $con = new AMQPLazySocketConnection( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getUser(), + $this->config->getPass(), + $this->config->getVHost(), + $this->config->getOption('insist'), + $this->config->getOption('login_method'), + $this->config->getOption('login_response'), + $this->config->getOption('locale'), + (int) round($this->config->getReadTimeout()), + $this->config->getOption('keepalive'), + (int) round($this->config->getWriteTimeout()), + (int) round($this->config->getHeartbeat()), + $this->config->getOption('channel_rpc_timeout') + ); + } else { + $con = new AMQPSocketConnection( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getUser(), + $this->config->getPass(), + $this->config->getVHost(), + $this->config->getOption('insist'), + $this->config->getOption('login_method'), + $this->config->getOption('login_response'), + $this->config->getOption('locale'), + (int) round($this->config->getReadTimeout()), + $this->config->getOption('keepalive'), + (int) round($this->config->getWriteTimeout()), + (int) round($this->config->getHeartbeat()), + $this->config->getOption('channel_rpc_timeout') + ); + } + } + + $this->connection = $con; + } + + return $this->connection; + } +} diff --git a/pkg/amqp-lib/AmqpConsumer.php b/pkg/amqp-lib/AmqpConsumer.php new file mode 100644 index 000000000..0534a9371 --- /dev/null +++ b/pkg/amqp-lib/AmqpConsumer.php @@ -0,0 +1,137 @@ +context = $context; + $this->channel = $context->getLibChannel(); + $this->queue = $queue; + $this->flags = self::FLAG_NOPARAM; + } + + public function setConsumerTag(?string $consumerTag = null): void + { + $this->consumerTag = $consumerTag; + } + + public function getConsumerTag(): ?string + { + return $this->consumerTag; + } + + public function clearFlags(): void + { + $this->flags = self::FLAG_NOPARAM; + } + + public function addFlag(int $flag): void + { + $this->flags |= $flag; + } + + public function getFlags(): int + { + return $this->flags; + } + + public function setFlags(int $flags): void + { + $this->flags = $flags; + } + + /** + * @return InteropAmqpQueue + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return InteropAmqpMessage + */ + public function receive(int $timeout = 0): ?Message + { + $end = microtime(true) + ($timeout / 1000); + + while (0 === $timeout || microtime(true) < $end) { + if ($message = $this->receiveNoWait()) { + return $message; + } + + usleep(100000); // 100ms + } + + return null; + } + + /** + * @return InteropAmqpMessage + */ + public function receiveNoWait(): ?Message + { + if ($message = $this->channel->basic_get($this->queue->getQueueName(), (bool) ($this->getFlags() & InteropAmqpConsumer::FLAG_NOACK))) { + return $this->context->convertMessage($message); + } + + return null; + } + + /** + * @param InteropAmqpMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + $this->channel->basic_ack($message->getDeliveryTag()); + } + + /** + * @param InteropAmqpMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + $this->channel->basic_reject($message->getDeliveryTag(), $requeue); + } +} diff --git a/pkg/amqp-lib/AmqpContext.php b/pkg/amqp-lib/AmqpContext.php new file mode 100644 index 000000000..34569659d --- /dev/null +++ b/pkg/amqp-lib/AmqpContext.php @@ -0,0 +1,318 @@ +config = array_replace([ + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + ], $config); + + $this->connection = $connection; + } + + /** + * @return InteropAmqpMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new AmqpMessage($body, $properties, $headers); + } + + /** + * @return InteropAmqpQueue + */ + public function createQueue(string $name): Queue + { + return new AmqpQueue($name); + } + + /** + * @return InteropAmqpTopic + */ + public function createTopic(string $name): Topic + { + return new AmqpTopic($name); + } + + /** + * @param InteropAmqpTopic|InteropAmqpQueue $destination + * + * @return AmqpConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + $destination instanceof Topic + ? InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpTopic::class) + : InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpQueue::class) + ; + + if ($destination instanceof AmqpTopic) { + $queue = $this->createTemporaryQueue(); + $this->bind(new AmqpBind($destination, $queue, $queue->getQueueName())); + + return new AmqpConsumer($this, $queue); + } + + return new AmqpConsumer($this, $destination); + } + + /** + * @return AmqpSubscriptionConsumer + */ + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new AmqpSubscriptionConsumer($this, (bool) $this->config['heartbeat_on_tick']); + } + + /** + * @return AmqpProducer + */ + public function createProducer(): Producer + { + $producer = new AmqpProducer($this->getLibChannel(), $this); + $producer->setDelayStrategy($this->delayStrategy); + + return $producer; + } + + /** + * @return InteropAmqpQueue + */ + public function createTemporaryQueue(): Queue + { + list($name) = $this->getLibChannel()->queue_declare('', false, false, true, false); + + $queue = $this->createQueue($name); + $queue->addFlag(InteropAmqpQueue::FLAG_EXCLUSIVE); + + return $queue; + } + + public function declareTopic(InteropAmqpTopic $topic): void + { + $this->getLibChannel()->exchange_declare( + $topic->getTopicName(), + $topic->getType(), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_PASSIVE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_DURABLE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_AUTODELETE), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_INTERNAL), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_NOWAIT), + $topic->getArguments() ? new AMQPTable($topic->getArguments()) : null + ); + } + + public function deleteTopic(InteropAmqpTopic $topic): void + { + $this->getLibChannel()->exchange_delete( + $topic->getTopicName(), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_IFUNUSED), + (bool) ($topic->getFlags() & InteropAmqpTopic::FLAG_NOWAIT) + ); + } + + public function declareQueue(InteropAmqpQueue $queue): int + { + list(, $messageCount) = $this->getLibChannel()->queue_declare( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_PASSIVE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_DURABLE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_EXCLUSIVE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_AUTODELETE), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT), + $queue->getArguments() ? new AMQPTable($queue->getArguments()) : null + ); + + return $messageCount ?? 0; + } + + public function deleteQueue(InteropAmqpQueue $queue): void + { + $this->getLibChannel()->queue_delete( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_IFUNUSED), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_IFEMPTY), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT) + ); + } + + /** + * @param AmqpQueue $queue + */ + public function purgeQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, InteropAmqpQueue::class); + + $this->getLibChannel()->queue_purge( + $queue->getQueueName(), + (bool) ($queue->getFlags() & InteropAmqpQueue::FLAG_NOWAIT) + ); + } + + public function bind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to bind queue to queue. It is possible to bind topic to queue or topic to topic'); + } + + // bind exchange to exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $this->getLibChannel()->exchange_bind( + $bind->getTarget()->getTopicName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + // bind queue to exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $this->getLibChannel()->queue_bind( + $bind->getSource()->getQueueName(), + $bind->getTarget()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + new AMQPTable($bind->getArguments()) + ); + // bind exchange to queue + } else { + $this->getLibChannel()->queue_bind( + $bind->getTarget()->getQueueName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + new AMQPTable($bind->getArguments()) + ); + } + } + + public function unbind(InteropAmqpBind $bind): void + { + if ($bind->getSource() instanceof InteropAmqpQueue && $bind->getTarget() instanceof InteropAmqpQueue) { + throw new Exception('Is not possible to bind queue to queue. It is possible to bind topic to queue or topic to topic'); + } + + // bind exchange to exchange + if ($bind->getSource() instanceof InteropAmqpTopic && $bind->getTarget() instanceof InteropAmqpTopic) { + $this->getLibChannel()->exchange_unbind( + $bind->getTarget()->getTopicName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + (bool) ($bind->getFlags() & InteropAmqpBind::FLAG_NOWAIT), + $bind->getArguments() + ); + // bind queue to exchange + } elseif ($bind->getSource() instanceof InteropAmqpQueue) { + $this->getLibChannel()->queue_unbind( + $bind->getSource()->getQueueName(), + $bind->getTarget()->getTopicName(), + $bind->getRoutingKey(), + $bind->getArguments() + ); + // bind exchange to queue + } else { + $this->getLibChannel()->queue_unbind( + $bind->getTarget()->getQueueName(), + $bind->getSource()->getTopicName(), + $bind->getRoutingKey(), + $bind->getArguments() + ); + } + } + + public function close(): void + { + if ($this->channel) { + $this->channel->close(); + } + } + + public function setQos(int $prefetchSize, int $prefetchCount, bool $global): void + { + $this->getLibChannel()->basic_qos($prefetchSize, $prefetchCount, $global); + } + + public function getLibChannel(): AMQPChannel + { + if (null === $this->channel) { + $this->channel = $this->connection->channel(); + $this->channel->basic_qos( + $this->config['qos_prefetch_size'], + $this->config['qos_prefetch_count'], + $this->config['qos_global'] + ); + } + + return $this->channel; + } + + /** + * @internal It must be used here and in the consumer only + */ + public function convertMessage(LibAMQPMessage $amqpMessage): InteropAmqpMessage + { + $headers = new AMQPTable($amqpMessage->get_properties()); + $headers = $headers->getNativeData(); + + $properties = []; + if (isset($headers['application_headers'])) { + $properties = $headers['application_headers']; + } + unset($headers['application_headers']); + + $message = new AmqpMessage($amqpMessage->getBody(), $properties, $headers); + $message->setDeliveryTag((int) $amqpMessage->getDeliveryTag()); + $message->setRedelivered($amqpMessage->isRedelivered()); + $message->setRoutingKey($amqpMessage->getRoutingKey()); + + return $message; + } +} diff --git a/pkg/amqp-lib/AmqpProducer.php b/pkg/amqp-lib/AmqpProducer.php new file mode 100644 index 000000000..928597298 --- /dev/null +++ b/pkg/amqp-lib/AmqpProducer.php @@ -0,0 +1,168 @@ +channel = $channel; + $this->context = $context; + } + + /** + * @param InteropAmqpTopic|InteropAmqpQueue $destination + * @param InteropAmqpMessage $message + */ + public function send(Destination $destination, Message $message): void + { + $destination instanceof Topic + ? InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpTopic::class) + : InvalidDestinationException::assertDestinationInstanceOf($destination, InteropAmqpQueue::class) + ; + + InvalidMessageException::assertMessageInstanceOf($message, InteropAmqpMessage::class); + + try { + $this->doSend($destination, $message); + } catch (\Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @return self + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $this->delayStrategy) { + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return self + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return self + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } + + private function doSend(InteropAmqpDestination $destination, InteropAmqpMessage $message): void + { + if (null !== $this->priority && null === $message->getPriority()) { + $message->setPriority($this->priority); + } + + if (null !== $this->timeToLive && null === $message->getExpiration()) { + $message->setExpiration($this->timeToLive); + } + + $amqpProperties = $message->getHeaders(); + + if ($appProperties = $message->getProperties()) { + $amqpProperties['application_headers'] = new AMQPTable($appProperties); + } + + $amqpMessage = new LibAMQPMessage($message->getBody(), $amqpProperties); + + if ($this->deliveryDelay) { + $this->delayStrategy->delayMessage($this->context, $destination, $message, $this->deliveryDelay); + } elseif ($destination instanceof InteropAmqpTopic) { + $this->channel->basic_publish( + $amqpMessage, + $destination->getTopicName(), + $message->getRoutingKey(), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_MANDATORY), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_IMMEDIATE) + ); + } else { + $this->channel->basic_publish( + $amqpMessage, + '', + $destination->getQueueName(), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_MANDATORY), + (bool) ($message->getFlags() & InteropAmqpMessage::FLAG_IMMEDIATE) + ); + } + } +} diff --git a/pkg/amqp-lib/AmqpSubscriptionConsumer.php b/pkg/amqp-lib/AmqpSubscriptionConsumer.php new file mode 100644 index 000000000..f96c4e49a --- /dev/null +++ b/pkg/amqp-lib/AmqpSubscriptionConsumer.php @@ -0,0 +1,164 @@ +subscribers = []; + $this->context = $context; + $this->heartbeatOnTick = $heartbeatOnTick; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('There is no subscribers. Consider calling basicConsumeSubscribe before consuming'); + } + + $signalHandler = new SignalSocketHelper(); + $signalHandler->beforeSocket(); + + $heartbeatOnTick = function (AmqpContext $context) { + $context->getLibChannel()->getConnection()->checkHeartBeat(); + }; + + $this->heartbeatOnTick && register_tick_function($heartbeatOnTick, $this->context); + + try { + while (true) { + $start = microtime(true); + + $this->context->getLibChannel()->wait(null, false, $timeout / 1000); + + if ($timeout <= 0) { + continue; + } + + // compute remaining timeout and continue until time is up + $stop = microtime(true); + $timeout -= ($stop - $start) * 1000; + + if ($timeout <= 0) { + break; + } + } + } catch (AMQPTimeoutException $e) { + } catch (StopBasicConsumptionException $e) { + } catch (AMQPIOWaitException $e) { + if ($signalHandler->wasThereSignal()) { + return; + } + + throw $e; + } finally { + $signalHandler->afterSocket(); + + $this->heartbeatOnTick && unregister_tick_function($heartbeatOnTick); + } + } + + /** + * @param AmqpConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if ($consumer->getConsumerTag() && array_key_exists($consumer->getConsumerTag(), $this->subscribers)) { + return; + } + + $libCallback = function (LibAMQPMessage $message) { + $receivedMessage = $this->context->convertMessage($message); + $receivedMessage->setConsumerTag($message->getConsumerTag()); + + /** + * @var AmqpConsumer + * @var callable $callback + */ + list($consumer, $callback) = $this->subscribers[$message->getConsumerTag()]; + + if (false === call_user_func($callback, $receivedMessage, $consumer)) { + throw new StopBasicConsumptionException(); + } + }; + + $consumerTag = $this->context->getLibChannel()->basic_consume( + $consumer->getQueue()->getQueueName(), + $consumer->getConsumerTag(), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOLOCAL), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOACK), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_EXCLUSIVE), + (bool) ($consumer->getFlags() & InteropAmqpConsumer::FLAG_NOWAIT), + $libCallback + ); + + if (empty($consumerTag)) { + throw new Exception('Got empty consumer tag'); + } + + $consumer->setConsumerTag($consumerTag); + + $this->subscribers[$consumerTag] = [$consumer, $callback]; + } + + /** + * @param AmqpConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof AmqpConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', AmqpConsumer::class, $consumer::class)); + } + + if (false == $consumer->getConsumerTag()) { + return; + } + + $consumerTag = $consumer->getConsumerTag(); + + $this->context->getLibChannel()->basic_cancel($consumerTag); + + $consumer->setConsumerTag(null); + unset($this->subscribers[$consumerTag], $this->context->getLibChannel()->callbacks[$consumerTag]); + } + + public function unsubscribeAll(): void + { + foreach ($this->subscribers as list($consumer)) { + $this->unsubscribe($consumer); + } + } +} diff --git a/pkg/amqp-lib/LICENSE b/pkg/amqp-lib/LICENSE new file mode 100644 index 000000000..681501120 --- /dev/null +++ b/pkg/amqp-lib/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Paul McLaren + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/amqp-lib/README.md b/pkg/amqp-lib/README.md new file mode 100644 index 000000000..f85ce7c5f --- /dev/null +++ b/pkg/amqp-lib/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# AMQP Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-lib/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-lib/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-lib/d/total.png)](https://packagist.org/packages/enqueue/amqp-lib) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-lib/version.png)](https://packagist.org/packages/enqueue/amqp-lib) + +This is an implementation of [amqp interop](https://github.com/queue-interop/amqp-interop). It uses [php-amqplib](https://github.com/php-amqplib/php-amqplib) internally. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/amqp_lib/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/amqp-lib/StopBasicConsumptionException.php b/pkg/amqp-lib/StopBasicConsumptionException.php new file mode 100644 index 000000000..99c9ea162 --- /dev/null +++ b/pkg/amqp-lib/StopBasicConsumptionException.php @@ -0,0 +1,9 @@ +assertClassImplements(ConnectionFactory::class, AmqpConnectionFactory::class); + } + + public function testShouldSetRabbitMqDlxDelayStrategyIfRabbitMqSchemeExtensionPresent() + { + $factory = new AmqpConnectionFactory('amqp+rabbitmq:'); + + $this->assertAttributeInstanceOf(RabbitMqDlxDelayStrategy::class, 'delayStrategy', $factory); + } +} diff --git a/pkg/amqp-lib/Tests/AmqpConsumerTest.php b/pkg/amqp-lib/Tests/AmqpConsumerTest.php new file mode 100644 index 000000000..3961e1ab9 --- /dev/null +++ b/pkg/amqp-lib/Tests/AmqpConsumerTest.php @@ -0,0 +1,198 @@ +assertClassImplements(Consumer::class, AmqpConsumer::class); + } + + public function testCouldBeConstructedWithContextAndQueueAsArguments() + { + self::assertInstanceOf(AmqpConsumer::class, + new AmqpConsumer( + $this->createContextMock(), + new AmqpQueue('aName') + ) + ); + } + + public function testShouldReturnQueue() + { + $queue = new AmqpQueue('aName'); + + $consumer = new AmqpConsumer($this->createContextMock(), $queue); + + $this->assertSame($queue, $consumer->getQueue()); + } + + public function testOnAcknowledgeShouldThrowExceptionIfNotAmqpMessage() + { + $consumer = new AmqpConsumer($this->createContextMock(), new AmqpQueue('aName')); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but'); + + $consumer->acknowledge(new NullMessage()); + } + + public function testOnRejectShouldThrowExceptionIfNotAmqpMessage() + { + $consumer = new AmqpConsumer($this->createContextMock(), new AmqpQueue('aName')); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but'); + + $consumer->reject(new NullMessage()); + } + + public function testOnAcknowledgeShouldAcknowledgeMessage() + { + $channel = $this->createLibChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_ack') + ->with(167) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getLibChannel') + ->willReturn($channel) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $message = new AmqpMessage(); + $message->setDeliveryTag(167); + + $consumer->acknowledge($message); + } + + public function testOnRejectShouldRejectMessage() + { + $channel = $this->createLibChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_reject') + ->with(125, $this->isTrue()) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getLibChannel') + ->willReturn($channel) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $message = new AmqpMessage(); + $message->setDeliveryTag(125); + + $consumer->reject($message, true); + } + + public function testShouldReturnMessageOnReceiveNoWait() + { + $libMessage = new \PhpAmqpLib\Message\AMQPMessage('body'); + $libMessage->setDeliveryInfo('delivery-tag', true, '', 'routing-key'); + + $message = new AmqpMessage(); + + $channel = $this->createLibChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_get') + ->willReturn($libMessage) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getLibChannel') + ->willReturn($channel) + ; + $context + ->expects($this->once()) + ->method('convertMessage') + ->with($this->identicalTo($libMessage)) + ->willReturn($message) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $receivedMessage = $consumer->receiveNoWait(); + + $this->assertSame($message, $receivedMessage); + } + + public function testShouldReturnMessageOnReceiveWithReceiveMethodBasicGet() + { + $libMessage = new \PhpAmqpLib\Message\AMQPMessage('body'); + $libMessage->setDeliveryInfo('delivery-tag', true, '', 'routing-key'); + + $message = new AmqpMessage(); + + $channel = $this->createLibChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_get') + ->willReturn($libMessage) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getLibChannel') + ->willReturn($channel) + ; + $context + ->expects($this->once()) + ->method('convertMessage') + ->with($this->identicalTo($libMessage)) + ->willReturn($message) + ; + + $consumer = new AmqpConsumer($context, new AmqpQueue('aName')); + + $receivedMessage = $consumer->receive(); + + $this->assertSame($message, $receivedMessage); + } + + /** + * @return MockObject|AmqpContext + */ + public function createContextMock() + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return MockObject|AMQPChannel + */ + public function createLibChannelMock() + { + return $this->createMock(AMQPChannel::class); + } +} diff --git a/pkg/amqp-lib/Tests/AmqpContextTest.php b/pkg/amqp-lib/Tests/AmqpContextTest.php new file mode 100644 index 000000000..4cfde3c14 --- /dev/null +++ b/pkg/amqp-lib/Tests/AmqpContextTest.php @@ -0,0 +1,368 @@ +createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchange_declare') + ->with( + $this->identicalTo('name'), + $this->identicalTo('type'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isInstanceOf(AMQPTable::class), + $this->isNull() + ) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $topic = new AmqpTopic('name'); + $topic->setType('type'); + $topic->setArguments(['key' => 'value']); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $topic->addFlag(AmqpTopic::FLAG_NOWAIT); + $topic->addFlag(AmqpTopic::FLAG_PASSIVE); + $topic->addFlag(AmqpTopic::FLAG_INTERNAL); + $topic->addFlag(AmqpTopic::FLAG_AUTODELETE); + + $session = new AmqpContext($connection, []); + $session->declareTopic($topic); + } + + public function testShouldDeleteTopic() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchange_delete') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue() + ) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $topic = new AmqpTopic('name'); + $topic->setType('type'); + $topic->setArguments(['key' => 'value']); + $topic->addFlag(AmqpTopic::FLAG_IFUNUSED); + $topic->addFlag(AmqpTopic::FLAG_NOWAIT); + + $session = new AmqpContext($connection, []); + $session->deleteTopic($topic); + } + + public function testShouldDeclareQueue() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queue_declare') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isTrue(), + $this->isInstanceOf(AMQPTable::class), + $this->isNull() + ) + ->willReturn([null, 123]) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $queue = new AmqpQueue('name'); + $queue->setArguments(['key' => 'value']); + $queue->addFlag(AmqpQueue::FLAG_AUTODELETE); + $queue->addFlag(AmqpQueue::FLAG_DURABLE); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + $queue->addFlag(AmqpQueue::FLAG_PASSIVE); + $queue->addFlag(AmqpQueue::FLAG_EXCLUSIVE); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $session = new AmqpContext($connection, []); + $session->declareQueue($queue); + } + + public function testShouldReturnCurrentMessageCountOnDeclareQueue() + { + $expectedCount = 1256; + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queue_declare') + ->willReturn([null, $expectedCount]) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $queue = new AmqpQueue('name'); + + $session = new AmqpContext($connection, []); + $actualCount = $session->declareQueue($queue); + + $this->assertSame($expectedCount, $actualCount); + } + + public function testShouldDeleteQueue() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queue_delete') + ->with( + $this->identicalTo('name'), + $this->isTrue(), + $this->isTrue(), + $this->isTrue() + ) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $queue = new AmqpQueue('name'); + $queue->setArguments(['key' => 'value']); + $queue->addFlag(AmqpQueue::FLAG_IFUNUSED); + $queue->addFlag(AmqpQueue::FLAG_IFEMPTY); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $session = new AmqpContext($connection, []); + $session->deleteQueue($queue); + } + + public function testBindShouldBindTopicToTopic() + { + $source = new AmqpTopic('source'); + $target = new AmqpTopic('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchange_bind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->bind(new AmqpBind($target, $source, 'routing-key', 12345)); + } + + public function testBindShouldBindTopicToQueue() + { + $source = new AmqpTopic('source'); + $target = new AmqpQueue('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->exactly(2)) + ->method('queue_bind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->bind(new AmqpBind($target, $source, 'routing-key', 12345)); + $context->bind(new AmqpBind($source, $target, 'routing-key', 12345)); + } + + public function testShouldUnBindTopicFromTopic() + { + $source = new AmqpTopic('source'); + $target = new AmqpTopic('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('exchange_unbind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), $this->isTrue()) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->unbind(new AmqpBind($target, $source, 'routing-key', 12345)); + } + + public function testShouldUnBindTopicFromQueue() + { + $source = new AmqpTopic('source'); + $target = new AmqpQueue('target'); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->exactly(2)) + ->method('queue_unbind') + ->with($this->identicalTo('target'), $this->identicalTo('source'), $this->identicalTo('routing-key'), ['key' => 'value']) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->unbind(new AmqpBind($target, $source, 'routing-key', 12345, ['key' => 'value'])); + $context->unbind(new AmqpBind($source, $target, 'routing-key', 12345, ['key' => 'value'])); + } + + public function testShouldCloseChannelConnection() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('close') + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->createProducer(); + + $context->close(); + } + + public function testShouldPurgeQueue() + { + $queue = new AmqpQueue('queue'); + $queue->addFlag(AmqpQueue::FLAG_NOWAIT); + + $channel = $this->createChannelMock(); + $channel + ->expects($this->once()) + ->method('queue_purge') + ->with($this->identicalTo('queue'), $this->isTrue()) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->purgeQueue($queue); + } + + public function testShouldSetQos() + { + $channel = $this->createChannelMock(); + $channel + ->expects($this->at(0)) + ->method('basic_qos') + ->with($this->identicalTo(0), $this->identicalTo(1), $this->isFalse()) + ; + $channel + ->expects($this->at(1)) + ->method('basic_qos') + ->with($this->identicalTo(123), $this->identicalTo(456), $this->isTrue()) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('channel') + ->willReturn($channel) + ; + + $context = new AmqpContext($connection, []); + $context->setQos(123, 456, true); + } + + public function testShouldReturnExpectedSubscriptionConsumerInstance() + { + $context = new AmqpContext($this->createConnectionMock(), ['heartbeat_on_tick' => true]); + + $this->assertInstanceOf(AmqpSubscriptionConsumer::class, $context->createSubscriptionConsumer()); + } + + /** + * @return MockObject|AbstractConnection + */ + public function createConnectionMock() + { + return $this->createMock(AbstractConnection::class); + } + + /** + * @return MockObject|AMQPChannel + */ + public function createChannelMock() + { + return $this->createMock(AMQPChannel::class); + } +} diff --git a/pkg/amqp-lib/Tests/AmqpProducerTest.php b/pkg/amqp-lib/Tests/AmqpProducerTest.php new file mode 100644 index 000000000..5746d911a --- /dev/null +++ b/pkg/amqp-lib/Tests/AmqpProducerTest.php @@ -0,0 +1,179 @@ +createAmqpChannelMock(), $this->createContextMock()) + ); + } + + public function testShouldImplementProducerInterface() + { + $this->assertClassImplements(Producer::class, AmqpProducer::class); + } + + public function testShouldThrowExceptionWhenDestinationTypeIsInvalid() + { + $producer = new AmqpProducer($this->createAmqpChannelMock(), $this->createContextMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpQueue but got'); + + $producer->send($this->createDestinationMock(), new AmqpMessage()); + } + + public function testShouldThrowExceptionWhenMessageTypeIsInvalid() + { + $producer = new AmqpProducer($this->createAmqpChannelMock(), $this->createContextMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Interop\Amqp\AmqpMessage but it is'); + + $producer->send(new AmqpTopic('name'), $this->createMessageMock()); + } + + public function testShouldPublishMessageToTopic() + { + $amqpMessage = null; + + $channel = $this->createAmqpChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_publish') + ->with($this->isInstanceOf(LibAMQPMessage::class), 'topic', 'routing-key') + ->willReturnCallback(function (LibAMQPMessage $message) use (&$amqpMessage) { + $amqpMessage = $message; + }) + ; + + $topic = new AmqpTopic('topic'); + + $message = new AmqpMessage('body'); + $message->setRoutingKey('routing-key'); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send($topic, $message); + + $this->assertEquals('body', $amqpMessage->getBody()); + } + + public function testShouldPublishMessageToQueue() + { + $amqpMessage = null; + + $channel = $this->createAmqpChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_publish') + ->with($this->isInstanceOf(LibAMQPMessage::class), $this->isEmpty(), 'queue') + ->willReturnCallback(function (LibAMQPMessage $message) use (&$amqpMessage) { + $amqpMessage = $message; + }) + ; + + $queue = new AmqpQueue('queue'); + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send($queue, new AmqpMessage('body')); + + $this->assertEquals('body', $amqpMessage->getBody()); + } + + public function testShouldSetMessageHeaders() + { + $amqpMessage = null; + + $channel = $this->createAmqpChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_publish') + ->willReturnCallback(function (LibAMQPMessage $message) use (&$amqpMessage) { + $amqpMessage = $message; + }) + ; + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send(new AmqpTopic('name'), new AmqpMessage('body', [], ['content_type' => 'text/plain'])); + + $this->assertEquals(['content_type' => 'text/plain'], $amqpMessage->get_properties()); + } + + public function testShouldSetMessageProperties() + { + $amqpMessage = null; + + $channel = $this->createAmqpChannelMock(); + $channel + ->expects($this->once()) + ->method('basic_publish') + ->willReturnCallback(function (LibAMQPMessage $message) use (&$amqpMessage) { + $amqpMessage = $message; + }) + ; + + $producer = new AmqpProducer($channel, $this->createContextMock()); + $producer->send(new AmqpTopic('name'), new AmqpMessage('body', ['key' => 'value'])); + + $properties = $amqpMessage->get_properties(); + + $this->assertArrayHasKey('application_headers', $properties); + $this->assertInstanceOf(AMQPTable::class, $properties['application_headers']); + $this->assertEquals(['key' => 'value'], $properties['application_headers']->getNativeData()); + } + + /** + * @return MockObject|Message + */ + private function createMessageMock() + { + return $this->createMock(Message::class); + } + + /** + * @return MockObject|Destination + */ + private function createDestinationMock() + { + return $this->createMock(Destination::class); + } + + /** + * @return MockObject|AMQPChannel + */ + private function createAmqpChannelMock() + { + return $this->createMock(AMQPChannel::class); + } + + /** + * @return MockObject|AmqpContext + */ + private function createContextMock() + { + return $this->createMock(AmqpContext::class); + } +} diff --git a/pkg/amqp-lib/Tests/AmqpSubscriptionConsumerTest.php b/pkg/amqp-lib/Tests/AmqpSubscriptionConsumerTest.php new file mode 100644 index 000000000..a375657b2 --- /dev/null +++ b/pkg/amqp-lib/Tests/AmqpSubscriptionConsumerTest.php @@ -0,0 +1,35 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + public function testCouldBeConstructedWithAmqpContextAndHeartbeatOnTickAsArguments() + { + self::assertInstanceOf( + AmqpSubscriptionConsumer::class, + new AmqpSubscriptionConsumer($this->createAmqpContextMock(), $heartbeatOnTick = true) + ); + } + + /** + * @return AmqpContext|MockObject + */ + private function createAmqpContextMock() + { + return $this->createMock(AmqpContext::class); + } +} diff --git a/pkg/amqp-lib/Tests/Functional/AmqpSubscriptionConsumerWithHeartbeatOnTickTest.php b/pkg/amqp-lib/Tests/Functional/AmqpSubscriptionConsumerWithHeartbeatOnTickTest.php new file mode 100644 index 000000000..4d5b695e5 --- /dev/null +++ b/pkg/amqp-lib/Tests/Functional/AmqpSubscriptionConsumerWithHeartbeatOnTickTest.php @@ -0,0 +1,83 @@ +context) { + $this->context->close(); + } + + parent::tearDown(); + } + + public function test() + { + $this->context = $context = $this->createContext(); + + $fooQueue = $this->createQueue($context, 'foo_subscription_consumer_consume_from_all_subscribed_queues_spec'); + + $expectedFooBody = 'fooBody'; + + $context->createProducer()->send($fooQueue, $context->createMessage($expectedFooBody)); + + $fooConsumer = $context->createConsumer($fooQueue); + + $actualBodies = []; + $actualQueues = []; + $callback = function (Message $message, Consumer $consumer) use (&$actualBodies, &$actualQueues) { + declare(ticks=1) { + $actualBodies[] = $message->getBody(); + $actualQueues[] = $consumer->getQueue()->getQueueName(); + + $consumer->acknowledge($message); + + return true; + } + }; + + $subscriptionConsumer = $context->createSubscriptionConsumer(); + $subscriptionConsumer->subscribe($fooConsumer, $callback); + + $subscriptionConsumer->consume(1000); + + $this->assertCount(1, $actualBodies); + } + + protected function createContext(): AmqpContext + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + + $context = $factory->createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + protected function createQueue(AmqpContext $context, string $queueName): AmqpQueue + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpConnectionFactoryTest.php b/pkg/amqp-lib/Tests/Spec/AmqpConnectionFactoryTest.php new file mode 100644 index 000000000..6fd1636e4 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpConnectionFactoryTest.php @@ -0,0 +1,14 @@ +createMock(AMQPChannel::class); + + $con = $this->createMock(AbstractConnection::class); + $con + ->expects($this->any()) + ->method('channel') + ->willReturn($channel) + ; + + return new AmqpContext($con, []); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpProducerTest.php b/pkg/amqp-lib/Tests/Spec/AmqpProducerTest.php new file mode 100644 index 000000000..f72296a66 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpProducerTest.php @@ -0,0 +1,19 @@ +createContext()->createProducer(); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php new file mode 100644 index 000000000..1a5fb70b3 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDelayPluginStrategyTest.php @@ -0,0 +1,36 @@ +setDelayStrategy(new RabbitMqDelayPluginDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php new file mode 100644 index 000000000..0e00b10e9 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveDelayedMessageWithDlxStrategyTest.php @@ -0,0 +1,36 @@ +setDelayStrategy(new RabbitMqDlxDelayStrategy()); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..83c4c948f --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $queue->setArguments(['x-max-priority' => 10]); + + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..d5f35ed65 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php new file mode 100644 index 000000000..ade42b346 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendAndReceiveTimestampAsIntengerTest.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..6d66532c6 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..621608020 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..db536948a --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,33 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..c2b184209 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,35 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..ec404b59e --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..665382fe4 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,50 @@ +createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + $context->bind(new AmqpBind($context->createTopic($queueName), $queue)); + + return $queue; + } + + /** + * @param AmqpContext $context + */ + protected function createTopic(Context $context, $topicName) + { + $topic = $context->createTopic($topicName); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + $context->declareTopic($topic); + + return $topic; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..7bf142e5d --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSslSendToAndReceiveFromQueueTest.php @@ -0,0 +1,47 @@ +assertNotEmpty($baseDir); + + $certDir = $baseDir.'/var/rabbitmq_certificates'; + $this->assertDirectoryExists($certDir); + + $factory = new AmqpConnectionFactory([ + 'dsn' => getenv('AMQPS_DSN'), + 'ssl_verify' => false, + 'ssl_cacert' => $certDir.'/cacert.pem', + 'ssl_cert' => $certDir.'/cert.pem', + 'ssl_key' => $certDir.'/key.pem', + ]); + + return $factory->createContext(); + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = $context->createQueue($queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php new file mode 100644 index 000000000..8017c9ac7 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerAddConsumerTagOnSubscribeTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..b81b139e8 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..288ab25f4 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,50 @@ +subscriptionConsumer) { + $this->subscriptionConsumer->unsubscribeAll(); + } + + parent::tearDown(); + } + + /** + * @return AmqpContext + */ + protected function createContext() + { + $factory = new AmqpConnectionFactory(getenv('AMQP_DSN')); + + $context = $factory->createContext(); + $context->setQos(0, 1, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php new file mode 100644 index 000000000..5f06a2ad2 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerPreFetchCountTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php new file mode 100644 index 000000000..196b7a962 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerRemoveConsumerTagOnUnsubscribeTest.php @@ -0,0 +1,20 @@ +createContext(); + } +} diff --git a/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..345007135 --- /dev/null +++ b/pkg/amqp-lib/Tests/Spec/AmqpSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,41 @@ +createContext(); + $context->setQos(0, 5, false); + + return $context; + } + + /** + * @param AmqpContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var AmqpQueue $queue */ + $queue = parent::createQueue($context, $queueName); + $context->declareQueue($queue); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/amqp-lib/composer.json b/pkg/amqp-lib/composer.json new file mode 100644 index 000000000..62f906c66 --- /dev/null +++ b/pkg/amqp-lib/composer.json @@ -0,0 +1,40 @@ +{ + "name": "enqueue/amqp-lib", + "type": "library", + "description": "Message Queue Amqp Transport", + "keywords": ["messaging", "queue", "amqp"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "php-amqplib/php-amqplib": "^3.2", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "enqueue/amqp-tools": "^0.10" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\AmqpLib\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/amqp-lib/examples/consume.php b/pkg/amqp-lib/examples/consume.php new file mode 100644 index 000000000..03f609c71 --- /dev/null +++ b/pkg/amqp-lib/examples/consume.php @@ -0,0 +1,42 @@ +createContext(); + +$queue = $context->createQueue('foo'); +$fooConsumer = $context->createConsumer($queue); + +$queue = $context->createQueue('bar'); +$barConsumer = $context->createConsumer($queue); + +$consumers = [$fooConsumer, $barConsumer]; + +$consumer = $consumers[rand(0, 1)]; + +while (true) { + if ($m = $consumer->receive(100)) { + echo $m->getBody(), \PHP_EOL; + $consumer->acknowledge($m); + } + + $consumer = $consumers[rand(0, 1)]; +} + +echo 'Done'."\n"; diff --git a/pkg/amqp-lib/examples/produce.php b/pkg/amqp-lib/examples/produce.php new file mode 100644 index 000000000..7527b2620 --- /dev/null +++ b/pkg/amqp-lib/examples/produce.php @@ -0,0 +1,57 @@ +createContext(); + +$topic = $context->createTopic('test.amqp.ext'); +$topic->addFlag(AmqpTopic::FLAG_DURABLE); +$topic->setType(AmqpTopic::TYPE_FANOUT); +// $topic->setArguments(['alternate-exchange' => 'foo']); + +$context->deleteTopic($topic); +$context->declareTopic($topic); + +$fooQueue = $context->createQueue('foo'); +$fooQueue->addFlag(AmqpQueue::FLAG_DURABLE); + +$context->deleteQueue($fooQueue); +$context->declareQueue($fooQueue); + +$context->bind(new AmqpBind($topic, $fooQueue)); + +$barQueue = $context->createQueue('bar'); +$barQueue->addFlag(AmqpQueue::FLAG_DURABLE); + +$context->deleteQueue($barQueue); +$context->declareQueue($barQueue); + +$context->bind(new AmqpBind($topic, $barQueue)); + +$message = $context->createMessage('Hello Bar!'); + +while (true) { + $context->createProducer()->send($fooQueue, $message); + $context->createProducer()->send($barQueue, $message); +} + +echo 'Done'."\n"; diff --git a/pkg/amqp-lib/phpunit.xml.dist b/pkg/amqp-lib/phpunit.xml.dist new file mode 100644 index 000000000..2c5fe1f6a --- /dev/null +++ b/pkg/amqp-lib/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/amqp-lib/tutorial/emit_log.php b/pkg/amqp-lib/tutorial/emit_log.php new file mode 100644 index 000000000..bc9fd4c2b --- /dev/null +++ b/pkg/amqp-lib/tutorial/emit_log.php @@ -0,0 +1,33 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('logs'); +$topic->setType(AmqpTopic::TYPE_FANOUT); + +$context->declareTopic($topic); + +$data = implode(' ', array_slice($argv, 1)); +if (empty($data)) { + $data = 'info: Hello World!'; +} +$message = $context->createMessage($data); + +$context->createProducer()->send($topic, $message); + +echo ' [x] Sent ', $data, "\n"; + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/emit_log_direct.php b/pkg/amqp-lib/tutorial/emit_log_direct.php new file mode 100644 index 000000000..87e890854 --- /dev/null +++ b/pkg/amqp-lib/tutorial/emit_log_direct.php @@ -0,0 +1,37 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('direct_logs'); +$topic->setType(AmqpTopic::TYPE_DIRECT); + +$context->declareTopic($topic); + +$severity = isset($argv[1]) && !empty($argv[1]) ? $argv[1] : 'info'; + +$data = implode(' ', array_slice($argv, 2)); +if (empty($data)) { + $data = 'Hello World!'; +} + +$message = $context->createMessage($data); +$message->setRoutingKey($severity); + +$context->createProducer()->send($topic, $message); + +echo ' [x] Sent ',$severity,':',$data," \n"; + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/emit_log_topic.php b/pkg/amqp-lib/tutorial/emit_log_topic.php new file mode 100644 index 000000000..ab181865c --- /dev/null +++ b/pkg/amqp-lib/tutorial/emit_log_topic.php @@ -0,0 +1,37 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('topic_logs'); +$topic->setType(AmqpTopic::TYPE_TOPIC); + +$context->declareTopic($topic); + +$routing_key = isset($argv[1]) && !empty($argv[1]) ? $argv[1] : 'anonymous.info'; + +$data = implode(' ', array_slice($argv, 2)); +if (empty($data)) { + $data = 'Hello World!'; +} + +$message = $context->createMessage($data); +$message->setRoutingKey($routing_key); + +$context->createProducer()->send($topic, $message); + +echo ' [x] Sent ',$routing_key,':',$data," \n"; + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/new_task.php b/pkg/amqp-lib/tutorial/new_task.php new file mode 100644 index 000000000..5c3c836f8 --- /dev/null +++ b/pkg/amqp-lib/tutorial/new_task.php @@ -0,0 +1,35 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$queue = $context->createQueue('task_queue'); +$queue->addFlag(AmqpQueue::FLAG_DURABLE); + +$context->declareQueue($queue); + +$data = implode(' ', array_slice($argv, 1)); +if (empty($data)) { + $data = 'Hello World!'; +} +$message = $context->createMessage($data); +$message->setDeliveryMode(AmqpMessage::DELIVERY_MODE_PERSISTENT); + +$context->createProducer()->send($queue, $message); + +echo ' [x] Sent ', $data, "\n"; + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/receive.php b/pkg/amqp-lib/tutorial/receive.php new file mode 100644 index 000000000..1dba8353f --- /dev/null +++ b/pkg/amqp-lib/tutorial/receive.php @@ -0,0 +1,32 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$queue = $context->createQueue('hello'); +$context->declareQueue($queue); + +$consumer = $context->createConsumer($queue); +$consumer->addFlag(AmqpConsumer::FLAG_NOACK); + +echo ' [*] Waiting for messages. To exit press CTRL+C', "\n"; + +while (true) { + if ($message = $consumer->receive()) { + echo ' [x] Received ', $message->getBody(), "\n"; + } +} + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/receive_logs.php b/pkg/amqp-lib/tutorial/receive_logs.php new file mode 100644 index 000000000..d28395f17 --- /dev/null +++ b/pkg/amqp-lib/tutorial/receive_logs.php @@ -0,0 +1,40 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('logs'); +$topic->setType(AmqpTopic::TYPE_FANOUT); + +$context->declareTopic($topic); + +$queue = $context->createTemporaryQueue(); + +$context->bind(new AmqpBind($topic, $queue)); + +$consumer = $context->createConsumer($queue); +$consumer->addFlag(AmqpConsumer::FLAG_NOACK); + +echo ' [*] Waiting for logs. To exit press CTRL+C', "\n"; + +while (true) { + if ($message = $consumer->receive()) { + echo ' [x] ', $message->getBody(), "\n"; + } +} + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/receive_logs_direct.php b/pkg/amqp-lib/tutorial/receive_logs_direct.php new file mode 100644 index 000000000..2962aa480 --- /dev/null +++ b/pkg/amqp-lib/tutorial/receive_logs_direct.php @@ -0,0 +1,48 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('direct_logs'); +$topic->setType(AmqpTopic::TYPE_DIRECT); + +$context->declareTopic($topic); + +$queue = $context->createTemporaryQueue(); + +$severities = array_slice($argv, 1); +if (empty($severities)) { + file_put_contents('php://stderr', "Usage: $argv[0] [info] [warning] [error]\n"); + exit(1); +} + +foreach ($severities as $severity) { + $context->bind(new AmqpBind($topic, $queue, $severity)); +} + +$consumer = $context->createConsumer($queue); +$consumer->addFlag(AmqpConsumer::FLAG_NOACK); + +echo ' [*] Waiting for logs. To exit press CTRL+C', "\n"; + +while (true) { + if ($message = $consumer->receive()) { + echo ' [x] '.$message->getRoutingKey().':'.$message->getBody()."\n"; + } +} + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/receive_logs_topic.php b/pkg/amqp-lib/tutorial/receive_logs_topic.php new file mode 100644 index 000000000..89bf9c0ff --- /dev/null +++ b/pkg/amqp-lib/tutorial/receive_logs_topic.php @@ -0,0 +1,48 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$topic = $context->createTopic('topic_logs'); +$topic->setType(AmqpTopic::TYPE_TOPIC); + +$context->declareTopic($topic); + +$queue = $context->createTemporaryQueue(); + +$binding_keys = array_slice($argv, 1); +if (empty($binding_keys)) { + file_put_contents('php://stderr', "Usage: $argv[0] [binding_key]\n"); + exit(1); +} + +foreach ($binding_keys as $binding_key) { + $context->bind(new AmqpBind($topic, $queue, $binding_key)); +} + +$consumer = $context->createConsumer($queue); +$consumer->addFlag(AmqpConsumer::FLAG_NOACK); + +echo ' [*] Waiting for logs. To exit press CTRL+C', "\n"; + +while (true) { + if ($message = $consumer->receive()) { + echo ' [x] '.$message->getRoutingKey().':'.$message->getBody()."\n"; + } +} + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/rpc_client.php b/pkg/amqp-lib/tutorial/rpc_client.php new file mode 100644 index 000000000..6ad091bc0 --- /dev/null +++ b/pkg/amqp-lib/tutorial/rpc_client.php @@ -0,0 +1,55 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +class rpc_client +{ + /** @var Interop\Amqp\AmqpContext */ + private $context; + + /** @var Interop\Amqp\AmqpQueue */ + private $callback_queue; + + public function __construct(array $config) + { + $this->context = (new AmqpConnectionFactory($config))->createContext(); + $this->callback_queue = $this->context->createTemporaryQueue(); + } + + public function call($n) + { + $corr_id = uniqid(); + + $message = $this->context->createMessage((string) $n); + $message->setCorrelationId($corr_id); + $message->setReplyTo($this->callback_queue->getQueueName()); + + $this->context->createProducer()->send( + $this->context->createQueue('rpc_queue'), + $message + ); + + $consumer = $this->context->createConsumer($this->callback_queue); + + while (true) { + if ($message = $consumer->receive()) { + if ($message->getCorrelationId() == $corr_id) { + return (int) $message->getBody(); + } + } + } + } +} + +$fibonacci_rpc = new FibonacciRpcClient($config); +$response = $fibonacci_rpc->call(30); +echo ' [.] Got ', $response, "\n"; diff --git a/pkg/amqp-lib/tutorial/rpc_server.php b/pkg/amqp-lib/tutorial/rpc_server.php new file mode 100644 index 000000000..241471684 --- /dev/null +++ b/pkg/amqp-lib/tutorial/rpc_server.php @@ -0,0 +1,53 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +function fib($n) +{ + if (0 == $n) { + return 0; + } + + if (1 == $n) { + return 1; + } + + return fib($n - 1) + fib($n - 2); +} + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); +$context->setQos(0, 1, false); + +$rpc_queue = $context->createQueue('rpc_queue'); +$context->declareQueue($rpc_queue); + +$consumer = $context->createConsumer($rpc_queue); + +echo " [x] Awaiting RPC requests\n"; + +while (true) { + if ($req = $consumer->receive()) { + $n = (int) $req->getBody(); + echo ' [.] fib(', $n, ")\n"; + + $msg = $context->createMessage((string) fib($n)); + $msg->setCorrelationId($req->getCorrelationId()); + + $reply_queue = $context->createQueue($req->getReplyTo()); + $context->createProducer()->send($reply_queue, $msg); + + $consumer->acknowledge($req); + } +} + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/send.php b/pkg/amqp-lib/tutorial/send.php new file mode 100644 index 000000000..5f1d89b62 --- /dev/null +++ b/pkg/amqp-lib/tutorial/send.php @@ -0,0 +1,26 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); + +$queue = $context->createQueue('hello'); +$context->declareQueue($queue); + +$message = $context->createMessage('Hello World!'); + +$context->createProducer()->send($queue, $message); + +echo " [x] Sent 'Hello World!'\n"; + +$context->close(); diff --git a/pkg/amqp-lib/tutorial/worker.php b/pkg/amqp-lib/tutorial/worker.php new file mode 100644 index 000000000..b9afe4d4e --- /dev/null +++ b/pkg/amqp-lib/tutorial/worker.php @@ -0,0 +1,37 @@ + 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', +]; + +$connection = new AmqpConnectionFactory($config); +$context = $connection->createContext(); +$context->setQos(0, 1, false); + +$queue = $context->createQueue('task_queue'); +$queue->addFlag(AmqpQueue::FLAG_DURABLE); + +$context->declareQueue($queue); + +$consumer = $context->createConsumer($queue); + +echo ' [*] Waiting for messages. To exit press CTRL+C', "\n"; + +while (true) { + if ($message = $consumer->receive()) { + echo ' [x] Received ', $message->getBody(), "\n"; + sleep(substr_count($message->getBody(), '.')); + echo ' [x] Done', "\n"; + $consumer->acknowledge($message); + } +} + +$context->close(); diff --git a/pkg/amqp-tools/.gitattributes b/pkg/amqp-tools/.gitattributes new file mode 100644 index 000000000..517547ea0 --- /dev/null +++ b/pkg/amqp-tools/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +.gitattributes export-ignore diff --git a/pkg/amqp-tools/.github/workflows/ci.yml b/pkg/amqp-tools/.github/workflows/ci.yml new file mode 100644 index 000000000..5448d7b1a --- /dev/null +++ b/pkg/amqp-tools/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/amqp-tools/ConnectionConfig.php b/pkg/amqp-tools/ConnectionConfig.php new file mode 100644 index 000000000..e1356c7cb --- /dev/null +++ b/pkg/amqp-tools/ConnectionConfig.php @@ -0,0 +1,422 @@ +inputConfig = $config; + + $this->supportedSchemes = []; + $this->defaultConfig = [ + 'host' => 'localhost', + 'port' => 5672, + 'user' => 'guest', + 'pass' => 'guest', + 'vhost' => '/', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'heartbeat' => 0, + 'persisted' => false, + 'lazy' => true, + 'qos_global' => false, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ]; + $this->schemeExtensions = []; + + $this->addSupportedScheme('amqp'); + $this->addSupportedScheme('amqps'); + } + + public function addSupportedScheme(string $schema): self + { + $this->supportedSchemes[] = $schema; + $this->supportedSchemes = array_unique($this->supportedSchemes); + + return $this; + } + + /** + * @param string $name + * + * @return self + */ + public function addDefaultOption($name, $value) + { + $this->defaultConfig[$name] = $value; + + return $this; + } + + /** + * @return self + */ + public function parse() + { + if (empty($this->inputConfig) || in_array($this->inputConfig, $this->supportedSchemes, true)) { + $config = []; + } elseif (is_string($this->inputConfig)) { + $config = $this->parseDsn($this->inputConfig); + } elseif (is_array($this->inputConfig)) { + $config = $this->inputConfig; + if (array_key_exists('dsn', $config)) { + $dsn = $config['dsn']; + unset($config['dsn']); + + if ($dsn) { + $config = array_replace($config, $this->parseDsn($dsn)); + } + } + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $config = array_replace($this->defaultConfig, $config); + $config['host'] = (string) $config['host']; + $config['port'] = (int) $config['port']; + $config['user'] = (string) $config['user']; + $config['pass'] = (string) $config['pass']; + $config['read_timeout'] = max((float) $config['read_timeout'], 0); + $config['write_timeout'] = max((float) $config['write_timeout'], 0); + $config['connection_timeout'] = max((float) $config['connection_timeout'], 0); + $config['heartbeat'] = max((float) $config['heartbeat'], 0); + $config['persisted'] = !empty($config['persisted']); + $config['lazy'] = !empty($config['lazy']); + $config['qos_global'] = !empty($config['qos_global']); + $config['qos_prefetch_count'] = max((int) $config['qos_prefetch_count'], 0); + $config['qos_prefetch_size'] = max((int) $config['qos_prefetch_size'], 0); + $config['ssl_on'] = !empty($config['ssl_on']); + $config['ssl_verify'] = !empty($config['ssl_verify']); + $config['ssl_cacert'] = (string) $config['ssl_cacert']; + $config['ssl_cert'] = (string) $config['ssl_cert']; + $config['ssl_key'] = (string) $config['ssl_key']; + $config['ssl_passphrase'] = (string) $config['ssl_passphrase']; + + $this->config = $config; + + return $this; + } + + /** + * @return string[] + */ + public function getSchemeExtensions(): array + { + return $this->schemeExtensions; + } + + /** + * @return string + */ + public function getHost() + { + return $this->getOption('host'); + } + + /** + * @return int + */ + public function getPort() + { + return $this->getOption('port'); + } + + /** + * @return string + */ + public function getUser() + { + return $this->getOption('user'); + } + + /** + * @return string + */ + public function getPass() + { + return $this->getOption('pass'); + } + + /** + * @return string + */ + public function getVHost() + { + return $this->getOption('vhost'); + } + + /** + * @return int + */ + public function getReadTimeout() + { + return $this->getOption('read_timeout'); + } + + /** + * @return int + */ + public function getWriteTimeout() + { + return $this->getOption('write_timeout'); + } + + /** + * @return int + */ + public function getConnectionTimeout() + { + return $this->getOption('connection_timeout'); + } + + /** + * @return int + */ + public function getHeartbeat() + { + return $this->getOption('heartbeat'); + } + + /** + * @return bool + */ + public function isPersisted() + { + return $this->getOption('persisted'); + } + + /** + * @return bool + */ + public function isLazy() + { + return $this->getOption('lazy'); + } + + /** + * @return bool + */ + public function isQosGlobal() + { + return $this->getOption('qos_global'); + } + + /** + * @return int + */ + public function getQosPrefetchSize() + { + return $this->getOption('qos_prefetch_size'); + } + + /** + * @return int + */ + public function getQosPrefetchCount() + { + return $this->getOption('qos_prefetch_count'); + } + + /** + * @return bool + */ + public function isSslOn() + { + return $this->getOption('ssl_on'); + } + + /** + * @return bool + */ + public function isSslVerify() + { + return $this->getOption('ssl_verify'); + } + + /** + * @return string + */ + public function getSslCaCert() + { + return $this->getOption('ssl_cacert'); + } + + /** + * @return string + */ + public function getSslCert() + { + return $this->getOption('ssl_cert'); + } + + /** + * @return string + */ + public function getSslKey() + { + return $this->getOption('ssl_key'); + } + + /** + * @return string + */ + public function getSslPassPhrase() + { + return $this->getOption('ssl_passphrase'); + } + + /** + * @param string $name + * @param mixed|null $default + */ + public function getOption($name, $default = null) + { + $config = $this->getConfig(); + + return array_key_exists($name, $config) ? $config[$name] : $default; + } + + /** + * @throws \LogicException if the input config has not been parsed + * + * @return array + */ + public function getConfig() + { + if (null === $this->config) { + throw new \LogicException('The config has not been parsed.'); + } + + return $this->config; + } + + /** + * @param string $dsn + * + * @return array + */ + private function parseDsn($dsn) + { + $dsn = Dsn::parseFirst($dsn); + + $supportedSchemes = $this->supportedSchemes; + if (false == in_array($dsn->getSchemeProtocol(), $supportedSchemes, true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be one of "%s".', $dsn->getSchemeProtocol(), implode('", "', $supportedSchemes))); + } + + $sslOn = false; + $isAmqps = 'amqps' === $dsn->getSchemeProtocol(); + $isTls = in_array('tls', $dsn->getSchemeExtensions(), true); + $isSsl = in_array('ssl', $dsn->getSchemeExtensions(), true); + if ($isAmqps || $isTls || $isSsl) { + $sslOn = true; + } + + $this->schemeExtensions = $dsn->getSchemeExtensions(); + + $config = array_filter(array_replace($dsn->getQuery(), [ + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'user' => $dsn->getUser(), + 'pass' => $dsn->getPassword(), + 'vhost' => null !== ($path = $dsn->getPath()) ? + (str_starts_with($path, '/') ? substr($path, 1) : $path) + : null, + 'read_timeout' => $dsn->getFloat('read_timeout'), + 'write_timeout' => $dsn->getFloat('write_timeout'), + 'connection_timeout' => $dsn->getFloat('connection_timeout'), + 'heartbeat' => $dsn->getFloat('heartbeat'), + 'persisted' => $dsn->getBool('persisted'), + 'lazy' => $dsn->getBool('lazy'), + 'qos_global' => $dsn->getBool('qos_global'), + 'qos_prefetch_size' => $dsn->getDecimal('qos_prefetch_size'), + 'qos_prefetch_count' => $dsn->getDecimal('qos_prefetch_count'), + 'ssl_on' => $sslOn, + 'ssl_verify' => $dsn->getBool('ssl_verify'), + 'ssl_cacert' => $dsn->getString('ssl_cacert'), + 'ssl_cert' => $dsn->getString('ssl_cert'), + 'ssl_key' => $dsn->getString('ssl_key'), + 'ssl_passphrase' => $dsn->getString('ssl_passphrase'), + ]), function ($value) { return null !== $value; }); + + return array_map(function ($value) { + return is_string($value) ? rawurldecode($value) : $value; + }, $config); + } +} diff --git a/pkg/amqp-tools/DelayStrategy.php b/pkg/amqp-tools/DelayStrategy.php new file mode 100644 index 000000000..791bc5519 --- /dev/null +++ b/pkg/amqp-tools/DelayStrategy.php @@ -0,0 +1,17 @@ +delayStrategy = $delayStrategy; + + return $this; + } +} diff --git a/pkg/amqp-tools/DelayStrategyTransportFactoryTrait.php b/pkg/amqp-tools/DelayStrategyTransportFactoryTrait.php new file mode 100644 index 000000000..0c6702228 --- /dev/null +++ b/pkg/amqp-tools/DelayStrategyTransportFactoryTrait.php @@ -0,0 +1,36 @@ +getDefinition($factoryId); + + if (false == (is_a($factory->getClass(), DelayStrategyAware::class, true) || $factory->getFactory())) { + throw new \LogicException('Connection factory does not support delays'); + } + + if ('dlx' === strtolower($config['delay_strategy'])) { + $delayId = sprintf('enqueue.client.%s.delay_strategy', $factoryName); + $container->register($delayId, RabbitMqDlxDelayStrategy::class); + + $factory->addMethodCall('setDelayStrategy', [new Reference($delayId)]); + } elseif ('delayed_message_plugin' === strtolower($config['delay_strategy'])) { + $delayId = sprintf('enqueue.client.%s.delay_strategy', $factoryName); + $container->register($delayId, RabbitMqDelayPluginDelayStrategy::class); + + $factory->addMethodCall('setDelayStrategy', [new Reference($delayId)]); + } else { + $factory->addMethodCall('setDelayStrategy', [new Reference($config['delay_strategy'])]); + } + } + } +} diff --git a/pkg/amqp-tools/LICENSE b/pkg/amqp-tools/LICENSE new file mode 100644 index 000000000..d9736f8bf --- /dev/null +++ b/pkg/amqp-tools/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/amqp-tools/README.md b/pkg/amqp-tools/README.md new file mode 100644 index 000000000..16cb1667f --- /dev/null +++ b/pkg/amqp-tools/README.md @@ -0,0 +1,37 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# AMQP tools + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/amqp-tools/ci.yml?branch=master)](https://github.com/php-enqueue/amqp-tools/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/amqp-tools/d/total.png)](https://packagist.org/packages/enqueue/amqp-tools) +[![Latest Stable Version](https://poser.pugx.org/enqueue/amqp-tools/version.png)](https://packagist.org/packages/enqueue/amqp-tools) + +Provides features that are not part of the AMQP spec but could be built on top of. +The tools could be used with any [amqp interop](https://github.com/queue-interop/queue-interop#amqp-interop) compatible transport. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/amqp-tools/RabbitMqDelayPluginDelayStrategy.php b/pkg/amqp-tools/RabbitMqDelayPluginDelayStrategy.php new file mode 100644 index 000000000..180d43bd9 --- /dev/null +++ b/pkg/amqp-tools/RabbitMqDelayPluginDelayStrategy.php @@ -0,0 +1,53 @@ +createMessage($message->getBody(), $message->getProperties(), $message->getHeaders()); + $delayMessage->setProperty('x-delay', (int) $delay); + $delayMessage->setRoutingKey($message->getRoutingKey()); + + if ($dest instanceof AmqpTopic) { + $delayTopic = $context->createTopic('enqueue.'.$dest->getTopicName().'.delayed'); + $delayTopic->setType('x-delayed-message'); + $delayTopic->addFlag($dest->getFlags()); + $delayTopic->setArgument('x-delayed-type', $dest->getType()); + + $context->declareTopic($delayTopic); + $context->bind(new AmqpBind($dest, $delayTopic, $delayMessage->getRoutingKey())); + } elseif ($dest instanceof AmqpQueue) { + $delayTopic = $context->createTopic('enqueue.queue.delayed'); + $delayTopic->setType('x-delayed-message'); + $delayTopic->addFlag(AmqpTopic::FLAG_DURABLE); + $delayTopic->setArgument('x-delayed-type', AmqpTopic::TYPE_DIRECT); + + $delayMessage->setRoutingKey($dest->getQueueName()); + + $context->declareTopic($delayTopic); + $context->bind(new AmqpBind($dest, $delayTopic, $delayMessage->getRoutingKey())); + } else { + throw new InvalidDestinationException(sprintf('The destination must be an instance of %s but got %s.', AmqpTopic::class.'|'.AmqpQueue::class, $dest::class)); + } + + $producer = $context->createProducer(); + + if ($producer instanceof DelayStrategyAware) { + $producer->setDelayStrategy(null); + } + + $producer->send($delayTopic, $delayMessage); + } +} diff --git a/pkg/amqp-tools/RabbitMqDlxDelayStrategy.php b/pkg/amqp-tools/RabbitMqDlxDelayStrategy.php new file mode 100644 index 000000000..35d9b59fe --- /dev/null +++ b/pkg/amqp-tools/RabbitMqDlxDelayStrategy.php @@ -0,0 +1,51 @@ +getProperties(); + + // The x-death header must be removed because of the bug in RabbitMQ. + // It was reported that the bug is fixed since 3.5.4 but I tried with 3.6.1 and the bug still there. + // https://github.com/rabbitmq/rabbitmq-server/issues/216 + unset($properties['x-death']); + + $delayMessage = $context->createMessage($message->getBody(), $properties, $message->getHeaders()); + $delayMessage->setRoutingKey($message->getRoutingKey()); + + if ($dest instanceof AmqpTopic) { + $routingKey = $message->getRoutingKey() ? '.'.$message->getRoutingKey() : ''; + $name = sprintf('enqueue.%s%s.%s.x.delay', $dest->getTopicName(), $routingKey, $delay); + + $delayQueue = $context->createQueue($name); + $delayQueue->addFlag(AmqpTopic::FLAG_DURABLE); + $delayQueue->setArgument('x-message-ttl', $delay); + $delayQueue->setArgument('x-dead-letter-exchange', $dest->getTopicName()); + $delayQueue->setArgument('x-dead-letter-routing-key', (string) $delayMessage->getRoutingKey()); + } elseif ($dest instanceof AmqpQueue) { + $delayQueue = $context->createQueue('enqueue.'.$dest->getQueueName().'.'.$delay.'.delayed'); + $delayQueue->addFlag(AmqpTopic::FLAG_DURABLE); + $delayQueue->setArgument('x-message-ttl', $delay); + $delayQueue->setArgument('x-dead-letter-exchange', ''); + $delayQueue->setArgument('x-dead-letter-routing-key', $dest->getQueueName()); + } else { + throw new InvalidDestinationException(sprintf('The destination must be an instance of %s but got %s.', AmqpTopic::class.'|'.AmqpQueue::class, $dest::class)); + } + + $context->declareQueue($delayQueue); + + $context->createProducer()->send($delayQueue, $delayMessage); + } +} diff --git a/pkg/amqp-tools/SignalSocketHelper.php b/pkg/amqp-tools/SignalSocketHelper.php new file mode 100644 index 000000000..623a5e3e2 --- /dev/null +++ b/pkg/amqp-tools/SignalSocketHelper.php @@ -0,0 +1,80 @@ +handlers = []; + } + + public function beforeSocket(): void + { + // PHP 7.1 and pcntl ext installed higher + if (false == function_exists('pcntl_signal_get_handler')) { + return; + } + + $signals = [\SIGTERM, \SIGQUIT, \SIGINT]; + + if ($this->handlers) { + throw new \LogicException('The handlers property should be empty but it is not. The afterSocket method might not have been called.'); + } + if (null !== $this->wasThereSignal) { + throw new \LogicException('The wasThereSignal property should be null but it is not. The afterSocket method might not have been called.'); + } + + $this->wasThereSignal = false; + + foreach ($signals as $signal) { + /** @var callable $handler */ + $handler = pcntl_signal_get_handler($signal); + + pcntl_signal($signal, function ($signal) use ($handler) { + $this->wasThereSignal = true; + + $handler && $handler($signal); + }); + + $handler && $this->handlers[$signal] = $handler; + } + } + + public function afterSocket(): void + { + // PHP 7.1 and higher + if (false == function_exists('pcntl_signal_get_handler')) { + return; + } + + $signals = [\SIGTERM, \SIGQUIT, \SIGINT]; + + $this->wasThereSignal = null; + + foreach ($signals as $signal) { + $handler = isset($this->handlers[$signal]) ? $this->handlers[$signal] : \SIG_DFL; + + pcntl_signal($signal, $handler); + } + + $this->handlers = []; + } + + public function wasThereSignal(): bool + { + return (bool) $this->wasThereSignal; + } +} diff --git a/pkg/amqp-tools/Tests/ConnectionConfigTest.php b/pkg/amqp-tools/Tests/ConnectionConfigTest.php new file mode 100644 index 000000000..1a1dc477d --- /dev/null +++ b/pkg/amqp-tools/Tests/ConnectionConfigTest.php @@ -0,0 +1,587 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + (new ConnectionConfig(new \stdClass()))->parse(); + } + + public function testThrowIfSchemeIsNotSupported() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be one of "amqp", "amqps".'); + + (new ConnectionConfig('http://example.com'))->parse(); + } + + public function testThrowIfSchemeIsNotSupportedIncludingAdditionalSupportedSchemes() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be one of "amqp", "amqps", "amqp+foo".'); + + (new ConnectionConfig('http://example.com')) + ->addSupportedScheme('amqp+foo') + ->parse() + ; + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + (new ConnectionConfig('foo'))->parse(); + } + + public function testShouldParseEmptyDsnWithDriverSet() + { + $config = (new ConnectionConfig('amqp+foo:')) + ->addSupportedScheme('amqp+foo') + ->parse() + ; + + $this->assertEquals([ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], $config->getConfig()); + } + + public function testShouldParseCustomDsnWithDriverSet() + { + $config = (new ConnectionConfig('amqp+foo://user:pass@host:10000/vhost')) + ->addSupportedScheme('amqp+foo') + ->parse() + ; + + $this->assertEquals([ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], $config->getConfig()); + } + + public function testShouldGetSchemeExtensions() + { + $config = (new ConnectionConfig('amqp+foo+bar:')) + ->addSupportedScheme('amqp') + ->parse() + ; + + $this->assertSame(['foo', 'bar'], $config->getSchemeExtensions()); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $config = new ConnectionConfig($config); + $config->parse(); + + $this->assertEquals($expectedConfig, $config->getConfig()); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + ['dsn' => null], + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp:', + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqps:', + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => true, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp+tls:', + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => true, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp+ssl:', + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => true, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://user:pass@host:10000/vhost', + [ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://user%61:%61pass@ho%61st:10000/v%2fhost', + [ + 'host' => 'hoast', + 'port' => 10000, + 'vhost' => 'v/host', + 'user' => 'usera', + 'pass' => 'apass', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://user:pass@host:10000/vhost?connection_timeout=20&write_timeout=4&read_timeout=-4&heartbeat=23.3', + [ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 0., + 'write_timeout' => 4, + 'connection_timeout' => 20., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 23.3, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://user:pass@host:10000/vhost?persisted=1&lazy=&qos_global=true', + [ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => true, + 'lazy' => false, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => true, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + [], + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + ['lazy' => false, 'persisted' => 1, 'qos_global' => 1], + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => true, + 'lazy' => false, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => true, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + ['qos_prefetch_count' => 123, 'qos_prefetch_size' => -2], + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_count' => 123, + 'qos_prefetch_size' => 0, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://user:pass@host:10000/vhost?qos_prefetch_count=123&qos_prefetch_size=-2', + [ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_count' => 123, + 'qos_prefetch_size' => 0, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + [ + 'read_timeout' => 20., + 'write_timeout' => 30., + 'connection_timeout' => 40., + 'qos_prefetch_count' => 10, + 'dsn' => 'amqp://user:pass@host:10000/vhost?qos_prefetch_count=20', + ], + [ + 'host' => 'host', + 'port' => 10000, + 'vhost' => 'vhost', + 'user' => 'user', + 'pass' => 'pass', + 'read_timeout' => 20., + 'write_timeout' => 30., + 'connection_timeout' => 40., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_count' => 20, + 'qos_prefetch_size' => 0, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + [ + 'ssl_on' => false, + 'dsn' => 'amqps:', + ], + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => true, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + + yield [ + 'amqp://guest:guest@localhost:5672/%2f', + [ + 'host' => 'localhost', + 'port' => 5672, + 'vhost' => '/', + 'user' => 'guest', + 'pass' => 'guest', + 'read_timeout' => 3., + 'write_timeout' => 3., + 'connection_timeout' => 3., + 'persisted' => false, + 'lazy' => true, + 'qos_prefetch_size' => 0, + 'qos_prefetch_count' => 1, + 'qos_global' => false, + 'heartbeat' => 0.0, + 'ssl_on' => false, + 'ssl_verify' => true, + 'ssl_cacert' => '', + 'ssl_cert' => '', + 'ssl_key' => '', + 'ssl_passphrase' => '', + ], + ]; + } +} diff --git a/pkg/amqp-tools/Tests/DelayStrategyTransportFactoryTraitTest.php b/pkg/amqp-tools/Tests/DelayStrategyTransportFactoryTraitTest.php new file mode 100644 index 000000000..879059497 --- /dev/null +++ b/pkg/amqp-tools/Tests/DelayStrategyTransportFactoryTraitTest.php @@ -0,0 +1,84 @@ +register('factoryId', DelayStrategyTransportFactoryImpl::class); + + $trait = new DelayStrategyTransportFactoryTraitImpl(); + $trait->registerDelayStrategy($container, ['delay_strategy' => 'dlx'], 'factoryId', 'name'); + + $factory = $container->getDefinition('factoryId'); + + $calls = $factory->getMethodCalls(); + + $this->assertSame('setDelayStrategy', $calls[0][0]); + $this->assertInstanceOf(Reference::class, $calls[0][1][0]); + $this->assertSame('enqueue.client.name.delay_strategy', (string) $calls[0][1][0]); + + $strategy = $container->getDefinition('enqueue.client.name.delay_strategy'); + + $this->assertSame(RabbitMqDlxDelayStrategy::class, $strategy->getClass()); + } + + public function testShouldRegisterDelayMessagePluginStrategy() + { + $container = new ContainerBuilder(); + $container->register('factoryId', DelayStrategyTransportFactoryImpl::class); + + $trait = new DelayStrategyTransportFactoryTraitImpl(); + $trait->registerDelayStrategy($container, ['delay_strategy' => 'delayed_message_plugin'], 'factoryId', 'name'); + + $factory = $container->getDefinition('factoryId'); + + $calls = $factory->getMethodCalls(); + + $this->assertSame('setDelayStrategy', $calls[0][0]); + $this->assertInstanceOf(Reference::class, $calls[0][1][0]); + $this->assertSame('enqueue.client.name.delay_strategy', (string) $calls[0][1][0]); + + $strategy = $container->getDefinition('enqueue.client.name.delay_strategy'); + + $this->assertSame(RabbitMqDelayPluginDelayStrategy::class, $strategy->getClass()); + } + + public function testShouldRegisterDelayStrategyService() + { + $container = new ContainerBuilder(); + $container->register('factoryId', DelayStrategyTransportFactoryImpl::class); + + $trait = new DelayStrategyTransportFactoryTraitImpl(); + $trait->registerDelayStrategy($container, ['delay_strategy' => 'service_name'], 'factoryId', 'name'); + + $factory = $container->getDefinition('factoryId'); + + $calls = $factory->getMethodCalls(); + + $this->assertSame('setDelayStrategy', $calls[0][0]); + $this->assertInstanceOf(Reference::class, $calls[0][1][0]); + $this->assertSame('service_name', (string) $calls[0][1][0]); + } +} + +class DelayStrategyTransportFactoryTraitImpl +{ + use DelayStrategyTransportFactoryTrait; +} + +class DelayStrategyTransportFactoryImpl implements DelayStrategyAware +{ + use DelayStrategyAwareTrait; +} diff --git a/pkg/amqp-tools/Tests/RabbitMqDelayPluginDelayStrategyTest.php b/pkg/amqp-tools/Tests/RabbitMqDelayPluginDelayStrategyTest.php new file mode 100644 index 000000000..d20506919 --- /dev/null +++ b/pkg/amqp-tools/Tests/RabbitMqDelayPluginDelayStrategyTest.php @@ -0,0 +1,224 @@ +assertClassImplements(DelayStrategy::class, RabbitMqDelayPluginDelayStrategy::class); + } + + public function testShouldSendDelayedMessageToTopic() + { + $delayedTopic = new AmqpTopic('the-topic'); + $delayedMessage = new AmqpMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($delayedTopic), $this->identicalTo($delayedMessage)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createTopic') + ->with($this->identicalTo('enqueue.the-topic.delayed')) + ->willReturn($delayedTopic) + ; + $context + ->expects($this->once()) + ->method('declareTopic') + ->with($this->identicalTo($delayedTopic)) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo('the body'), $this->identicalTo(['key' => 'value']), $this->identicalTo(['hkey' => 'hvalue'])) + ->willReturn($delayedMessage) + ; + $context + ->expects($this->once()) + ->method('bind') + ->with($this->isInstanceOf(AmqpBind::class)) + ; + + $message = new AmqpMessage('the body', ['key' => 'value'], ['hkey' => 'hvalue']); + $message->setRoutingKey('the-routing-key'); + + $dest = new AmqpTopic('the-topic'); + $dest->setFlags(12345); + + $strategy = new RabbitMqDelayPluginDelayStrategy(); + $strategy->delayMessage($context, $dest, $message, 10000); + + $this->assertSame(12345, $delayedTopic->getFlags()); + $this->assertSame('x-delayed-message', $delayedTopic->getType()); + $this->assertSame([ + 'x-delayed-type' => 'direct', + ], $delayedTopic->getArguments()); + + $this->assertSame(['x-delay' => 10000], $delayedMessage->getProperties()); + $this->assertSame('the-routing-key', $delayedMessage->getRoutingKey()); + } + + public function testShouldSendDelayedMessageToQueue() + { + $delayedTopic = new AmqpTopic('the-topic'); + $delayedMessage = new AmqpMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($delayedTopic), $this->identicalTo($delayedMessage)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createTopic') + ->with($this->identicalTo('enqueue.queue.delayed')) + ->willReturn($delayedTopic) + ; + $context + ->expects($this->once()) + ->method('declareTopic') + ->with($this->identicalTo($delayedTopic)) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo('the body'), $this->identicalTo(['key' => 'value']), $this->identicalTo(['hkey' => 'hvalue'])) + ->willReturn($delayedMessage) + ; + $context + ->expects($this->once()) + ->method('bind') + ->with($this->isInstanceOf(AmqpBind::class)) + ; + + $message = new AmqpMessage('the body', ['key' => 'value'], ['hkey' => 'hvalue']); + $message->setRoutingKey('the-routing-key'); + + $dest = new AmqpQueue('the-queue'); + + $strategy = new RabbitMqDelayPluginDelayStrategy(); + $strategy->delayMessage($context, $dest, $message, 10000); + + $this->assertSame(AmqpQueue::FLAG_DURABLE, $delayedTopic->getFlags()); + $this->assertSame('x-delayed-message', $delayedTopic->getType()); + $this->assertSame([ + 'x-delayed-type' => 'direct', + ], $delayedTopic->getArguments()); + + $this->assertSame(['x-delay' => 10000], $delayedMessage->getProperties()); + $this->assertSame('the-queue', $delayedMessage->getRoutingKey()); + } + + public function testShouldThrowExceptionIfInvalidDestination() + { + $delayedMessage = new AmqpMessage(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($delayedMessage) + ; + + $strategy = new RabbitMqDelayPluginDelayStrategy(); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpTopic|Interop\Amqp\AmqpQueue but got'); + + $strategy->delayMessage($context, $this->createMock(AmqpDestination::class), new AmqpMessage(), 10000); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|AmqpContext + */ + private function createContextMock() + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|TestProducer + */ + private function createProducerMock() + { + return $this->createMock(TestProducer::class); + } +} + +class TestProducer implements AmqpProducer, DelayStrategy +{ + public function delayMessage(AmqpContext $context, AmqpDestination $dest, \Interop\Amqp\AmqpMessage $message, int $delay): void + { + } + + public function send(Destination $destination, Message $message): void + { + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function getDeliveryDelay(): ?int + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setPriority(?int $priority = null): Producer + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function getPriority(): ?int + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function getTimeToLive(): ?int + { + throw new \BadMethodCallException('This should not be called directly'); + } +} diff --git a/pkg/amqp-tools/Tests/RabbitMqDlxDelayStrategyTest.php b/pkg/amqp-tools/Tests/RabbitMqDlxDelayStrategyTest.php new file mode 100644 index 000000000..f519f8da3 --- /dev/null +++ b/pkg/amqp-tools/Tests/RabbitMqDlxDelayStrategyTest.php @@ -0,0 +1,199 @@ +assertClassImplements(DelayStrategy::class, RabbitMqDlxDelayStrategy::class); + } + + public function testShouldSendDelayedMessageToTopic() + { + $delayedQueue = new AmqpQueue('the-queue'); + $delayedMessage = new AmqpMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($delayedQueue), $this->identicalTo($delayedMessage)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->identicalTo('enqueue.the-topic.the-routing-key.10000.x.delay')) + ->willReturn($delayedQueue) + ; + $context + ->expects($this->once()) + ->method('declareQueue') + ->with($this->identicalTo($delayedQueue)) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo('the body'), $this->identicalTo(['key' => 'value']), $this->identicalTo(['hkey' => 'hvalue'])) + ->willReturn($delayedMessage) + ; + + $message = new AmqpMessage('the body', ['key' => 'value'], ['hkey' => 'hvalue']); + $message->setRoutingKey('the-routing-key'); + + $dest = new AmqpTopic('the-topic'); + + $strategy = new RabbitMqDlxDelayStrategy(); + $strategy->delayMessage($context, $dest, $message, 10000); + + $this->assertSame(AmqpQueue::FLAG_DURABLE, $delayedQueue->getFlags()); + $this->assertSame([ + 'x-message-ttl' => 10000, + 'x-dead-letter-exchange' => 'the-topic', + 'x-dead-letter-routing-key' => 'the-routing-key', + ], $delayedQueue->getArguments()); + } + + public function testShouldSendDelayedMessageToQueue() + { + $delayedQueue = new AmqpQueue('the-queue'); + $delayedMessage = new AmqpMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($delayedQueue), $this->identicalTo($delayedMessage)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->identicalTo('enqueue.the-queue.10000.delayed')) + ->willReturn($delayedQueue) + ; + $context + ->expects($this->once()) + ->method('declareQueue') + ->with($this->identicalTo($delayedQueue)) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo('the body'), $this->identicalTo(['key' => 'value']), $this->identicalTo(['hkey' => 'hvalue'])) + ->willReturn($delayedMessage) + ; + + $message = new AmqpMessage('the body', ['key' => 'value'], ['hkey' => 'hvalue']); + $message->setRoutingKey('the-routing-key'); + + $dest = new AmqpQueue('the-queue'); + + $strategy = new RabbitMqDlxDelayStrategy(); + $strategy->delayMessage($context, $dest, $message, 10000); + + $this->assertSame(AmqpQueue::FLAG_DURABLE, $delayedQueue->getFlags()); + $this->assertSame([ + 'x-message-ttl' => 10000, + 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => 'the-queue', + ], $delayedQueue->getArguments()); + } + + public function testShouldUnsetXDeathProperty() + { + $delayedQueue = new AmqpQueue('the-queue'); + $delayedMessage = new AmqpMessage(); + + $producer = $this->createProducerMock(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->identicalTo('enqueue.the-queue.10000.delayed')) + ->willReturn($delayedQueue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo('the body'), $this->identicalTo(['key' => 'value']), $this->identicalTo(['hkey' => 'hvalue'])) + ->willReturn($delayedMessage) + ; + + $message = new AmqpMessage('the body', ['key' => 'value', 'x-death' => 'value'], ['hkey' => 'hvalue']); + + $dest = new AmqpQueue('the-queue'); + + $strategy = new RabbitMqDlxDelayStrategy(); + $strategy->delayMessage($context, $dest, $message, 10000); + } + + public function testShouldThrowExceptionIfInvalidDestination() + { + $delayedMessage = new AmqpMessage(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($delayedMessage) + ; + + $strategy = new RabbitMqDlxDelayStrategy(); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Interop\Amqp\AmqpTopic|Interop\Amqp\AmqpQueue but got'); + + $strategy->delayMessage($context, $this->createMock(AmqpDestination::class), new AmqpMessage(), 10000); + } + + /** + * @return MockObject|AmqpContext + */ + private function createContextMock() + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return MockObject|AmqpProducer + */ + private function createProducerMock() + { + return $this->createMock(AmqpProducer::class); + } +} diff --git a/pkg/amqp-tools/Tests/SignalSocketHelperTest.php b/pkg/amqp-tools/Tests/SignalSocketHelperTest.php new file mode 100644 index 000000000..a44e42a70 --- /dev/null +++ b/pkg/amqp-tools/Tests/SignalSocketHelperTest.php @@ -0,0 +1,128 @@ +markTestSkipped('PHP 7.1+ needed'); + } + + $this->backupSigTermHandler = pcntl_signal_get_handler(\SIGTERM); + $this->backupSigIntHandler = pcntl_signal_get_handler(\SIGINT); + + pcntl_signal(\SIGTERM, \SIG_DFL); + pcntl_signal(\SIGINT, \SIG_DFL); + + $this->signalHelper = new SignalSocketHelper(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->signalHelper) { + $this->signalHelper->afterSocket(); + } + + if ($this->backupSigTermHandler) { + pcntl_signal(\SIGTERM, $this->backupSigTermHandler); + } + + if ($this->backupSigIntHandler) { + pcntl_signal(\SIGINT, $this->backupSigIntHandler); + } + } + + public function testShouldReturnFalseByDefault() + { + $this->assertFalse($this->signalHelper->wasThereSignal()); + } + + public function testShouldRegisterHandlerOnBeforeSocket() + { + $this->signalHelper->beforeSocket(); + + $this->assertAttributeSame(false, 'wasThereSignal', $this->signalHelper); + $this->assertAttributeSame([], 'handlers', $this->signalHelper); + } + + public function testShouldRegisterHandlerOnBeforeSocketAndBackupCurrentOne() + { + $handler = function () {}; + + pcntl_signal(\SIGTERM, $handler); + + $this->signalHelper->beforeSocket(); + + $this->assertAttributeSame(false, 'wasThereSignal', $this->signalHelper); + + $handlers = $this->readAttribute($this->signalHelper, 'handlers'); + + self::assertIsArray($handlers); + $this->assertArrayHasKey(\SIGTERM, $handlers); + $this->assertSame($handler, $handlers[\SIGTERM]); + } + + public function testRestoreDefaultPropertiesOnAfterSocket() + { + $this->signalHelper->beforeSocket(); + $this->signalHelper->afterSocket(); + + $this->assertAttributeSame(null, 'wasThereSignal', $this->signalHelper); + $this->assertAttributeSame([], 'handlers', $this->signalHelper); + } + + public function testRestorePreviousHandlerOnAfterSocket() + { + $handler = function () {}; + + pcntl_signal(\SIGTERM, $handler); + + $this->signalHelper->beforeSocket(); + $this->signalHelper->afterSocket(); + + $this->assertSame($handler, pcntl_signal_get_handler(\SIGTERM)); + } + + public function testThrowsIfBeforeSocketCalledSecondTime() + { + $this->signalHelper->beforeSocket(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The wasThereSignal property should be null but it is not. The afterSocket method might not have been called.'); + $this->signalHelper->beforeSocket(); + } + + public function testShouldReturnTrueOnWasThereSignal() + { + $this->signalHelper->beforeSocket(); + + posix_kill(getmypid(), \SIGINT); + pcntl_signal_dispatch(); + + $this->assertTrue($this->signalHelper->wasThereSignal()); + + $this->signalHelper->afterSocket(); + } +} diff --git a/pkg/amqp-tools/composer.json b/pkg/amqp-tools/composer.json new file mode 100644 index 000000000..966e065e8 --- /dev/null +++ b/pkg/amqp-tools/composer.json @@ -0,0 +1,38 @@ +{ + "name": "enqueue/amqp-tools", + "type": "library", + "description": "Message Queue Amqp Tools", + "keywords": ["messaging", "queue", "amqp"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "enqueue/dsn": "^0.10" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\AmqpTools\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/async-command/.gitattributes b/pkg/async-command/.gitattributes new file mode 100644 index 000000000..f13d4d91b --- /dev/null +++ b/pkg/async-command/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore diff --git a/pkg/async-command/.github/workflows/ci.yml b/pkg/async-command/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/async-command/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/async-command/.gitignore b/pkg/async-command/.gitignore new file mode 100644 index 000000000..c19bea911 --- /dev/null +++ b/pkg/async-command/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ \ No newline at end of file diff --git a/pkg/async-command/CommandResult.php b/pkg/async-command/CommandResult.php new file mode 100644 index 000000000..10080d587 --- /dev/null +++ b/pkg/async-command/CommandResult.php @@ -0,0 +1,62 @@ +exitCode = $exitCode; + $this->output = $output; + $this->errorOutput = $errorOutput; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getErrorOutput(): string + { + return $this->errorOutput; + } + + public function jsonSerialize(): array + { + return [ + 'exitCode' => $this->exitCode, + 'output' => $this->output, + 'errorOutput' => $this->errorOutput, + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['exitCode'], $data['output'], $data['errorOutput']); + } +} diff --git a/pkg/async-command/Commands.php b/pkg/async-command/Commands.php new file mode 100644 index 000000000..abc015cf8 --- /dev/null +++ b/pkg/async-command/Commands.php @@ -0,0 +1,8 @@ + $client, + 'command_name' => Commands::RUN_COMMAND, + 'queue_name' => Commands::RUN_COMMAND, + 'timeout' => 60, + ]; + } + + $id = sprintf('enqueue.async_command.%s.run_command_processor', $client['name']); + $container->register($id, RunCommandProcessor::class) + ->addArgument('%kernel.project_dir%') + ->addArgument($client['timeout']) + ->addTag('enqueue.processor', [ + 'client' => $client['name'], + 'command' => $client['command_name'] ?? Commands::RUN_COMMAND, + 'queue' => $client['queue_name'] ?? Commands::RUN_COMMAND, + 'prefix_queue' => false, + 'exclusive' => true, + ]) + ->addTag('enqueue.transport.processor') + ; + } + } +} diff --git a/pkg/async-command/LICENSE b/pkg/async-command/LICENSE new file mode 100644 index 000000000..bd25f8e13 --- /dev/null +++ b/pkg/async-command/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 Max Kotliar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/pkg/async-command/README.md b/pkg/async-command/README.md new file mode 100644 index 000000000..711e97163 --- /dev/null +++ b/pkg/async-command/README.md @@ -0,0 +1,37 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Symfony Async Command. + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/async-command/ci.yml?branch=master)](https://github.com/php-enqueue/async-command/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/async-command/d/total.png)](https://packagist.org/packages/enqueue/async-command) +[![Latest Stable Version](https://poser.pugx.org/enqueue/async-command/version.png)](https://packagist.org/packages/enqueue/async-command) + +It contains an extension to Symfony's [Console](https://symfony.com/doc/current/components/console.html) component. +It allows to execute Symfony's command async by sending the request to message queue. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/async-command/RunCommand.php b/pkg/async-command/RunCommand.php new file mode 100644 index 000000000..573a6200b --- /dev/null +++ b/pkg/async-command/RunCommand.php @@ -0,0 +1,72 @@ +command = $command; + $this->arguments = $arguments; + $this->options = $options; + } + + public function getCommand(): string + { + return $this->command; + } + + /** + * @return string[] + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * @return string[] + */ + public function getOptions(): array + { + return $this->options; + } + + public function jsonSerialize(): array + { + return [ + 'command' => $this->command, + 'arguments' => $this->arguments, + 'options' => $this->options, + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['command'], $data['arguments'], $data['options']); + } +} diff --git a/pkg/async-command/RunCommandProcessor.php b/pkg/async-command/RunCommandProcessor.php new file mode 100644 index 000000000..2c4462f90 --- /dev/null +++ b/pkg/async-command/RunCommandProcessor.php @@ -0,0 +1,66 @@ +projectDir = $projectDir; + $this->timeout = $timeout; + } + + public function process(Message $message, Context $context): Result + { + $command = RunCommand::jsonUnserialize($message->getBody()); + + $phpBin = (new PhpExecutableFinder())->find(); + $consoleBin = file_exists($this->projectDir.'/bin/console') ? './bin/console' : './app/console'; + + $process = new Process(array_merge( + [$phpBin, $consoleBin, $command->getCommand()], + $command->getArguments(), + $this->getCommandLineOptions($command) + ), $this->projectDir); + $process->setTimeout($this->timeout); + $process->run(); + + if ($message->getReplyTo()) { + $result = new CommandResult($process->getExitCode(), $process->getOutput(), $process->getErrorOutput()); + + return Result::reply($context->createMessage(json_encode($result))); + } + + return Result::ack(); + } + + /** + * @return string[] + */ + private function getCommandLineOptions(RunCommand $command): array + { + $options = []; + foreach ($command->getOptions() as $name => $value) { + $options[] = "$name=$value"; + } + + return $options; + } +} diff --git a/pkg/async-command/Tests/CommandResultTest.php b/pkg/async-command/Tests/CommandResultTest.php new file mode 100644 index 000000000..03a615502 --- /dev/null +++ b/pkg/async-command/Tests/CommandResultTest.php @@ -0,0 +1,69 @@ +assertTrue($rc->implementsInterface(\JsonSerializable::class)); + } + + public function testShouldBeFinal() + { + $rc = new \ReflectionClass(CommandResult::class); + + $this->assertTrue($rc->isFinal()); + } + + public function testShouldAllowGetExitCodeSetInConstructor() + { + $result = new CommandResult(123, '', ''); + + $this->assertSame(123, $result->getExitCode()); + } + + public function testShouldAllowGetOutputSetInConstructor() + { + $result = new CommandResult(0, 'theOutput', ''); + + $this->assertSame('theOutput', $result->getOutput()); + } + + public function testShouldAllowGetErrorOutputSetInConstructor() + { + $result = new CommandResult(0, '', 'theErrorOutput'); + + $this->assertSame('theErrorOutput', $result->getErrorOutput()); + } + + public function testShouldSerializeAndUnserialzeCommand() + { + $result = new CommandResult(123, 'theOutput', 'theErrorOutput'); + + $jsonCommand = json_encode($result); + + // guard + $this->assertNotEmpty($jsonCommand); + + $unserializedResult = CommandResult::jsonUnserialize($jsonCommand); + + $this->assertInstanceOf(CommandResult::class, $unserializedResult); + $this->assertSame(123, $unserializedResult->getExitCode()); + $this->assertSame('theOutput', $unserializedResult->getOutput()); + $this->assertSame('theErrorOutput', $unserializedResult->getErrorOutput()); + } + + public function testThrowExceptionIfInvalidJsonGiven() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The malformed json given.'); + + CommandResult::jsonUnserialize('{]'); + } +} diff --git a/pkg/async-command/Tests/Functional/UseCasesTest.php b/pkg/async-command/Tests/Functional/UseCasesTest.php new file mode 100644 index 000000000..03eb52543 --- /dev/null +++ b/pkg/async-command/Tests/Functional/UseCasesTest.php @@ -0,0 +1,41 @@ +setReplyTo('aReplyToQueue'); + + $processor = new RunCommandProcessor(__DIR__); + + $result = $processor->process($Message, new NullContext()); + + $this->assertInstanceOf(Result::class, $result); + $this->assertInstanceOf(Message::class, $result->getReply()); + + $replyMessage = $result->getReply(); + + $commandResult = CommandResult::jsonUnserialize($replyMessage->getBody()); + + $this->assertSame(123, $commandResult->getExitCode()); + $this->assertSame('Command Output', $commandResult->getOutput()); + $this->assertSame('Command Error Output', $commandResult->getErrorOutput()); + } +} diff --git a/pkg/async-command/Tests/Functional/bin/console b/pkg/async-command/Tests/Functional/bin/console new file mode 100644 index 000000000..40d7e3583 --- /dev/null +++ b/pkg/async-command/Tests/Functional/bin/console @@ -0,0 +1,6 @@ +assertTrue($rc->implementsInterface(Processor::class)); + } + + public function testShouldBeFinal() + { + $rc = new \ReflectionClass(RunCommandProcessor::class); + + $this->assertTrue($rc->isFinal()); + } + + public function testCouldBeConstructedWithProjectDirAsFirstArgument() + { + $processor = new RunCommandProcessor('aProjectDir'); + + $this->assertAttributeSame('aProjectDir', 'projectDir', $processor); + } + + public function testCouldBeConstructedWithTimeoutAsSecondArgument() + { + $processor = new RunCommandProcessor('aProjectDir', 60); + + $this->assertAttributeSame(60, 'timeout', $processor); + } +} diff --git a/pkg/async-command/Tests/RunCommandTest.php b/pkg/async-command/Tests/RunCommandTest.php new file mode 100644 index 000000000..a673e06f3 --- /dev/null +++ b/pkg/async-command/Tests/RunCommandTest.php @@ -0,0 +1,87 @@ +assertTrue($rc->implementsInterface(\JsonSerializable::class)); + } + + public function testShouldBeFinal() + { + $rc = new \ReflectionClass(RunCommand::class); + + $this->assertTrue($rc->isFinal()); + } + + public function testShouldAllowGetCommandSetInConstructor() + { + $command = new RunCommand('theCommand'); + + $this->assertSame('theCommand', $command->getCommand()); + } + + public function testShouldReturnEmptyArrayByDefaultOnGetArguments() + { + $command = new RunCommand('aCommand'); + + $this->assertSame([], $command->getArguments()); + } + + public function testShouldReturnEmptyArrayByDefaultOnGetOptions() + { + $command = new RunCommand('aCommand'); + + $this->assertSame([], $command->getOptions()); + } + + public function testShouldReturnArgumentsSetInConstructor() + { + $command = new RunCommand('aCommand', ['theArgument' => 'theValue']); + + $this->assertSame(['theArgument' => 'theValue'], $command->getArguments()); + } + + public function testShouldReturnOptionsSetInConstructor() + { + $command = new RunCommand('aCommand', [], ['theOption' => 'theValue']); + + $this->assertSame(['theOption' => 'theValue'], $command->getOptions()); + } + + public function testShouldSerializeAndUnserialzeCommand() + { + $command = new RunCommand( + 'theCommand', + ['theArgument' => 'theValue'], + ['theOption' => 'theValue'] + ); + + $jsonCommand = json_encode($command); + + // guard + $this->assertNotEmpty($jsonCommand); + + $unserializedCommand = RunCommand::jsonUnserialize($jsonCommand); + + $this->assertInstanceOf(RunCommand::class, $unserializedCommand); + $this->assertSame('theCommand', $unserializedCommand->getCommand()); + $this->assertSame(['theArgument' => 'theValue'], $unserializedCommand->getArguments()); + $this->assertSame(['theOption' => 'theValue'], $unserializedCommand->getOptions()); + } + + public function testThrowExceptionIfInvalidJsonGiven() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The malformed json given.'); + + RunCommand::jsonUnserialize('{]'); + } +} diff --git a/pkg/async-command/composer.json b/pkg/async-command/composer.json new file mode 100644 index 000000000..95d57ce3a --- /dev/null +++ b/pkg/async-command/composer.json @@ -0,0 +1,47 @@ +{ + "name": "enqueue/async-command", + "type": "library", + "description": "Symfony async command", + "keywords": ["messaging", "queue", "async command", "console", "cli"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "enqueue/enqueue": "^0.10", + "queue-interop/queue-interop": "^0.8", + "symfony/console": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0", + "enqueue/null": "0.10.x-dev", + "enqueue/fs": "0.10.x-dev", + "enqueue/test": "0.10.x-dev" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "suggest": { + "symfony/dependency-injection": "^5.4|^6.0 If you'd like to use async event dispatcher container extension." + }, + "autoload": { + "psr-4": { "Enqueue\\AsyncCommand\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/async-event-dispatcher/.gitattributes b/pkg/async-event-dispatcher/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/async-event-dispatcher/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/async-event-dispatcher/.github/workflows/ci.yml b/pkg/async-event-dispatcher/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/async-event-dispatcher/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/async-event-dispatcher/.gitignore b/pkg/async-event-dispatcher/.gitignore new file mode 100644 index 000000000..243f6687a --- /dev/null +++ b/pkg/async-event-dispatcher/.gitignore @@ -0,0 +1,7 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ +Tests/Functional/queues \ No newline at end of file diff --git a/pkg/async-event-dispatcher/AbstractAsyncEventDispatcher.php b/pkg/async-event-dispatcher/AbstractAsyncEventDispatcher.php new file mode 100644 index 000000000..5bc7d270a --- /dev/null +++ b/pkg/async-event-dispatcher/AbstractAsyncEventDispatcher.php @@ -0,0 +1,46 @@ +trueEventDispatcher = $trueEventDispatcher; + $this->asyncListener = $asyncListener; + } + + /** + * This method dispatches only those listeners that were marked as async. + * + * @param string $eventName + * @param ContractEvent|Event|null $event + */ + public function dispatchAsyncListenersOnly($eventName, $event = null) + { + try { + $this->asyncListener->syncMode($eventName); + + $this->parentDispatch($event, $eventName); + } finally { + $this->asyncListener->resetSyncMode(); + } + } + + abstract protected function parentDispatch($event, $eventName); +} diff --git a/pkg/async-event-dispatcher/AbstractAsyncListener.php b/pkg/async-event-dispatcher/AbstractAsyncListener.php new file mode 100644 index 000000000..d4ac19a1f --- /dev/null +++ b/pkg/async-event-dispatcher/AbstractAsyncListener.php @@ -0,0 +1,62 @@ +context = $context; + $this->registry = $registry; + $this->eventQueue = $eventQueue instanceof Queue ? $eventQueue : $context->createQueue($eventQueue); + } + + public function resetSyncMode() + { + $this->syncMode = []; + } + + /** + * @param string $eventName + */ + public function syncMode($eventName) + { + $this->syncMode[$eventName] = true; + } + + /** + * @param string $eventName + * + * @return bool + */ + public function isSyncMode($eventName) + { + return isset($this->syncMode[$eventName]); + } +} diff --git a/pkg/async-event-dispatcher/AbstractPhpSerializerEventTransformer.php b/pkg/async-event-dispatcher/AbstractPhpSerializerEventTransformer.php new file mode 100644 index 000000000..6ac53cbc2 --- /dev/null +++ b/pkg/async-event-dispatcher/AbstractPhpSerializerEventTransformer.php @@ -0,0 +1,24 @@ +context = $context; + } + + public function toEvent($eventName, Message $message) + { + return unserialize($message->getBody()); + } +} diff --git a/pkg/async-event-dispatcher/AsyncEventDispatcher.php b/pkg/async-event-dispatcher/AsyncEventDispatcher.php new file mode 100644 index 000000000..e39136eff --- /dev/null +++ b/pkg/async-event-dispatcher/AsyncEventDispatcher.php @@ -0,0 +1,18 @@ +parentDispatch($event, $eventName); + + return $this->trueEventDispatcher->dispatch($event, $eventName); + } + + protected function parentDispatch($event, $eventName) + { + return parent::dispatch($event, $eventName); + } +} diff --git a/pkg/async-event-dispatcher/AsyncListener.php b/pkg/async-event-dispatcher/AsyncListener.php new file mode 100644 index 000000000..2be4976fb --- /dev/null +++ b/pkg/async-event-dispatcher/AsyncListener.php @@ -0,0 +1,29 @@ +onEvent($event, $eventName); + } + + /** + * @param string $eventName + */ + public function onEvent(Event $event, $eventName) + { + if (false == isset($this->syncMode[$eventName])) { + $transformerName = $this->registry->getTransformerNameForEvent($eventName); + + $message = $this->registry->getTransformer($transformerName)->toMessage($eventName, $event); + $message->setProperty('event_name', $eventName); + $message->setProperty('transformer_name', $transformerName); + + $this->context->createProducer()->send($this->eventQueue, $message); + } + } +} diff --git a/pkg/async-event-dispatcher/AsyncProcessor.php b/pkg/async-event-dispatcher/AsyncProcessor.php new file mode 100644 index 000000000..dc61c5381 --- /dev/null +++ b/pkg/async-event-dispatcher/AsyncProcessor.php @@ -0,0 +1,49 @@ +registry = $registry; + + if (false == $dispatcher instanceof AsyncEventDispatcher) { + throw new \InvalidArgumentException(sprintf('The dispatcher argument must be instance of "%s" but got "%s"', AsyncEventDispatcher::class, $dispatcher::class)); + } + + $this->dispatcher = $dispatcher; + } + + public function process(Message $message, Context $context) + { + if (false == $eventName = $message->getProperty('event_name')) { + return Result::reject('The message is missing "event_name" property'); + } + if (false == $transformerName = $message->getProperty('transformer_name')) { + return Result::reject('The message is missing "transformer_name" property'); + } + + $event = $this->registry->getTransformer($transformerName)->toEvent($eventName, $message); + + $this->dispatcher->dispatchAsyncListenersOnly($eventName, $event); + + return self::ACK; + } +} diff --git a/pkg/async-event-dispatcher/Commands.php b/pkg/async-event-dispatcher/Commands.php new file mode 100644 index 000000000..c2263ee38 --- /dev/null +++ b/pkg/async-event-dispatcher/Commands.php @@ -0,0 +1,8 @@ + transformerName] + * @param string[] $transformersMap [transformerName => transformerServiceId] + */ + public function __construct(array $eventsMap, array $transformersMap, ContainerInterface $container) + { + $this->eventsMap = $eventsMap; + $this->transformersMap = $transformersMap; + $this->container = $container; + } + + public function getTransformerNameForEvent($eventName) + { + $transformerName = null; + if (array_key_exists($eventName, $this->eventsMap)) { + $transformerName = $this->eventsMap[$eventName]; + } else { + foreach ($this->eventsMap as $eventNamePattern => $name) { + if ('/' !== $eventNamePattern[0]) { + continue; + } + + if (preg_match($eventNamePattern, $eventName)) { + $transformerName = $name; + + break; + } + } + } + + if (empty($transformerName)) { + throw new \LogicException(sprintf('There is no transformer registered for the given event %s', $eventName)); + } + + return $transformerName; + } + + public function getTransformer($name) + { + if (false == array_key_exists($name, $this->transformersMap)) { + throw new \LogicException(sprintf('There is no transformer named %s', $name)); + } + + $transformer = $this->container->get($this->transformersMap[$name]); + + if (false == $transformer instanceof EventTransformer) { + throw new \LogicException(sprintf('The container must return instance of %s but got %s', EventTransformer::class, is_object($transformer) ? $transformer::class : gettype($transformer))); + } + + return $transformer; + } +} diff --git a/pkg/async-event-dispatcher/DependencyInjection/AsyncEventDispatcherExtension.php b/pkg/async-event-dispatcher/DependencyInjection/AsyncEventDispatcherExtension.php new file mode 100644 index 000000000..0b16ca650 --- /dev/null +++ b/pkg/async-event-dispatcher/DependencyInjection/AsyncEventDispatcherExtension.php @@ -0,0 +1,37 @@ +processConfiguration(new Configuration(), $configs); + + $container->setAlias('enqueue.events.context', new Alias($config['context_service'], true)); + + $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.yml'); + + $container->register('enqueue.events.async_processor', AsyncProcessor::class) + ->addArgument(new Reference('enqueue.events.registry')) + ->addArgument(new Reference('enqueue.events.event_dispatcher')) + ->addTag('enqueue.processor', [ + 'command' => Commands::DISPATCH_ASYNC_EVENTS, + 'queue' => '%enqueue_events_queue%', + 'prefix_queue' => false, + 'exclusive' => true, + ]) + ->addTag('enqueue.transport.processor') + ; + } +} diff --git a/pkg/async-event-dispatcher/DependencyInjection/AsyncEventsPass.php b/pkg/async-event-dispatcher/DependencyInjection/AsyncEventsPass.php new file mode 100644 index 000000000..42774adf7 --- /dev/null +++ b/pkg/async-event-dispatcher/DependencyInjection/AsyncEventsPass.php @@ -0,0 +1,101 @@ +hasDefinition('enqueue.events.async_listener')) { + return; + } + + if (false == $container->hasDefinition('enqueue.events.registry')) { + return; + } + + $defaultClient = $container->getParameter('enqueue.default_client'); + + $registeredToEvent = []; + foreach ($container->findTaggedServiceIds('kernel.event_listener') as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + if (false == isset($tagAttribute['async'])) { + continue; + } + + $event = $tagAttribute['event']; + + if (false == isset($registeredToEvent[$event])) { + $container->getDefinition('enqueue.events.async_listener') + ->addTag('kernel.event_listener', [ + 'event' => $event, + 'method' => 'onEvent', + ]) + ; + + $container->getDefinition('enqueue.events.async_listener') + ->addTag('kernel.event_listener', [ + 'event' => $event, + 'method' => 'onEvent', + 'dispatcher' => 'enqueue.events.event_dispatcher', + ]) + ; + + $container->getDefinition('enqueue.events.async_processor') + ->addTag('enqueue.processor', [ + 'topic' => 'event.'.$event, + 'client' => $defaultClient, + ]) + ; + + $registeredToEvent[$event] = true; + } + } + } + + foreach ($container->findTaggedServiceIds('kernel.event_subscriber') as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + if (false == isset($tagAttribute['async'])) { + continue; + } + + $service = $container->getDefinition($serviceId); + + /** @var EventSubscriberInterface $serviceClass */ + $serviceClass = $service->getClass(); + + foreach ($serviceClass::getSubscribedEvents() as $event => $data) { + if (false == isset($registeredToEvent[$event])) { + $container->getDefinition('enqueue.events.async_listener') + ->addTag('kernel.event_listener', [ + 'event' => $event, + 'method' => 'onEvent', + ]) + ; + + $container->getDefinition('enqueue.events.async_listener') + ->addTag('kernel.event_listener', [ + 'event' => $event, + 'method' => 'onEvent', + 'dispatcher' => 'enqueue.events.event_dispatcher', + ]) + ; + + $container->getDefinition('enqueue.events.async_processor') + ->addTag('enqueue.processor', [ + 'topicName' => 'event.'.$event, + 'client' => $defaultClient, + ]) + ; + + $registeredToEvent[$event] = true; + } + } + } + } + } +} diff --git a/pkg/async-event-dispatcher/DependencyInjection/AsyncTransformersPass.php b/pkg/async-event-dispatcher/DependencyInjection/AsyncTransformersPass.php new file mode 100644 index 000000000..89046dd58 --- /dev/null +++ b/pkg/async-event-dispatcher/DependencyInjection/AsyncTransformersPass.php @@ -0,0 +1,52 @@ +hasDefinition('enqueue.events.registry')) { + return; + } + + $transformerIdsMap = []; + $eventNamesMap = []; + $defaultTransformer = null; + foreach ($container->findTaggedServiceIds('enqueue.event_transformer') as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + if (false == isset($tagAttribute['eventName'])) { + throw new \LogicException('The eventName attribute must be set'); + } + + $eventName = $tagAttribute['eventName']; + + $transformerName = isset($tagAttribute['transformerName']) ? $tagAttribute['transformerName'] : $serviceId; + + if (isset($tagAttribute['default']) && $tagAttribute['default']) { + $defaultTransformer = [ + 'id' => $serviceId, + 'transformerName' => $transformerName, + 'eventName' => $eventName, + ]; + } else { + $eventNamesMap[$eventName] = $transformerName; + $transformerIdsMap[$transformerName] = $serviceId; + } + } + } + + if ($defaultTransformer) { + $eventNamesMap[$defaultTransformer['eventName']] = $defaultTransformer['transformerName']; + $transformerIdsMap[$defaultTransformer['transformerName']] = $defaultTransformer['id']; + } + + $container->getDefinition('enqueue.events.registry') + ->replaceArgument(0, $eventNamesMap) + ->replaceArgument(1, $transformerIdsMap) + ; + } +} diff --git a/pkg/async-event-dispatcher/DependencyInjection/Configuration.php b/pkg/async-event-dispatcher/DependencyInjection/Configuration.php new file mode 100644 index 000000000..7b85a469d --- /dev/null +++ b/pkg/async-event-dispatcher/DependencyInjection/Configuration.php @@ -0,0 +1,26 @@ +getRootNode(); + } else { + $tb = new TreeBuilder(); + $rootNode = $tb->root('enqueue_async_event_dispatcher'); + } + + $rootNode->children() + ->scalarNode('context_service')->isRequired()->cannotBeEmpty()->end() + ; + + return $tb; + } +} diff --git a/pkg/async-event-dispatcher/EventTransformer.php b/pkg/async-event-dispatcher/EventTransformer.php new file mode 100644 index 000000000..271dffa08 --- /dev/null +++ b/pkg/async-event-dispatcher/EventTransformer.php @@ -0,0 +1,27 @@ +context->createMessage(serialize($event)); + } +} diff --git a/pkg/async-event-dispatcher/README.md b/pkg/async-event-dispatcher/README.md new file mode 100644 index 000000000..c4804d981 --- /dev/null +++ b/pkg/async-event-dispatcher/README.md @@ -0,0 +1,37 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Symfony Async Event Dispatcher. + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/async-event-dispatcher/ci.yml?branch=master)](https://github.com/php-enqueue/async-event-dispathcer/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/async-event-dispathcer/d/total.png)](https://packagist.org/packages/enqueue/async-event-dispatcher) +[![Latest Stable Version](https://poser.pugx.org/enqueue/async-event-dispathcer/version.png)](https://packagist.org/packages/enqueue/async-event-dispatcher) + +It contains an extension to Symfony's [EventDispatcher](https://symfony.com/doc/current/components/event_dispatcher.html) component. +It allows to process events in background by sending them to MQ. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/async-event-dispatcher/Registry.php b/pkg/async-event-dispatcher/Registry.php new file mode 100644 index 000000000..e7cfe440a --- /dev/null +++ b/pkg/async-event-dispatcher/Registry.php @@ -0,0 +1,20 @@ + transformerName] + * @param string[] $transformersMap [transformerName => transformerObject] + */ + public function __construct(array $eventsMap, array $transformersMap) + { + $this->eventsMap = $eventsMap; + $this->transformersMap = $transformersMap; + } + + public function getTransformerNameForEvent($eventName) + { + $transformerName = null; + if (array_key_exists($eventName, $this->eventsMap)) { + $transformerName = $this->eventsMap[$eventName]; + } else { + foreach ($this->eventsMap as $eventNamePattern => $name) { + if ('/' != $eventNamePattern[0]) { + continue; + } + + if (preg_match($eventNamePattern, $eventName)) { + $transformerName = $name; + + break; + } + } + } + + if (empty($transformerName)) { + throw new \LogicException(sprintf('There is no transformer registered for the given event %s', $eventName)); + } + + return $transformerName; + } + + public function getTransformer($name) + { + if (false == array_key_exists($name, $this->transformersMap)) { + throw new \LogicException(sprintf('There is no transformer named %s', $name)); + } + + $transformer = $this->transformersMap[$name]; + + if (false == $transformer instanceof EventTransformer) { + throw new \LogicException(sprintf('The container must return instance of %s but got %s', EventTransformer::class, is_object($transformer) ? $transformer::class : gettype($transformer))); + } + + return $transformer; + } +} diff --git a/pkg/async-event-dispatcher/Tests/AsyncListenerTest.php b/pkg/async-event-dispatcher/Tests/AsyncListenerTest.php new file mode 100644 index 000000000..d888c0228 --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/AsyncListenerTest.php @@ -0,0 +1,165 @@ +createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with('symfony_events') + ->willReturn($eventQueue) + ; + + $listener = new AsyncListener($context, $this->createRegistryMock(), 'symfony_events'); + + $this->assertAttributeSame($eventQueue, 'eventQueue', $listener); + } + + public function testCouldBeConstructedWithContextAndRegistryAndQueue() + { + $eventQueue = new NullQueue('symfony_events'); + + $context = $this->createContextMock(); + $context + ->expects($this->never()) + ->method('createQueue') + ; + + $listener = new AsyncListener($context, $this->createRegistryMock(), $eventQueue); + + $this->assertAttributeSame($eventQueue, 'eventQueue', $listener); + } + + public function testShouldDoNothingIfSyncModeOn() + { + $producer = $this->createContextMock(); + $producer + ->expects($this->never()) + ->method('createProducer') + ; + + $registry = $this->createRegistryMock(); + $registry + ->expects($this->never()) + ->method('getTransformerNameForEvent') + ; + + $listener = new AsyncListener($producer, $registry, new NullQueue('symfony_events')); + + $listener->syncMode('fooEvent'); + + $listener->onEvent(new Event(), 'fooEvent'); + $listener->onEvent(new GenericEvent(), 'fooEvent'); + } + + public function testShouldSendMessageIfSyncModeOff() + { + $event = new GenericEvent(); + + $message = new NullMessage('serializedEvent'); + $eventQueue = new NullQueue('symfony_events'); + + $transformerMock = $this->createEventTransformerMock(); + $transformerMock + ->expects($this->once()) + ->method('toMessage') + ->with('fooEvent', $this->identicalTo($event)) + ->willReturn($message) + ; + + $registry = $this->createRegistryMock(); + $registry + ->expects($this->once()) + ->method('getTransformerNameForEvent') + ->with('fooEvent') + ->willReturn('fooTrans') + ; + $registry + ->expects($this->once()) + ->method('getTransformer') + ->with('fooTrans') + ->willReturn($transformerMock) + ; + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($eventQueue), $this->identicalTo($message)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->with() + ->willReturn($producer) + ; + + $listener = new AsyncListener($context, $registry, $eventQueue); + + $listener->onEvent($event, 'fooEvent'); + + $this->assertEquals('serializedEvent', $message->getBody()); + $this->assertEquals([], $message->getHeaders()); + $this->assertEquals([ + 'event_name' => 'fooEvent', + 'transformer_name' => 'fooTrans', + ], $message->getProperties()); + } + + /** + * @return MockObject|EventTransformer + */ + private function createEventTransformerMock() + { + return $this->createMock(EventTransformer::class); + } + + /** + * @return MockObject|Producer + */ + private function createProducerMock() + { + return $this->createMock(Producer::class); + } + + /** + * @return MockObject|Context + */ + private function createContextMock() + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject|Registry + */ + private function createRegistryMock() + { + return $this->createMock(Registry::class); + } +} diff --git a/pkg/async-event-dispatcher/Tests/AsyncProcessorTest.php b/pkg/async-event-dispatcher/Tests/AsyncProcessorTest.php new file mode 100644 index 000000000..019f9bcbe --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/AsyncProcessorTest.php @@ -0,0 +1,118 @@ +assertClassImplements(Processor::class, AsyncProcessor::class); + } + + public function testRejectIfMessageMissingEventNameProperty() + { + $processor = new AsyncProcessor($this->createRegistryMock(), $this->createProxyEventDispatcherMock()); + + $result = $processor->process(new NullMessage(), new NullContext()); + + $this->assertInstanceOf(Result::class, $result); + $this->assertEquals(Result::REJECT, $result->getStatus()); + $this->assertEquals('The message is missing "event_name" property', $result->getReason()); + } + + public function testRejectIfMessageMissingTransformerNameProperty() + { + $processor = new AsyncProcessor($this->createRegistryMock(), $this->createProxyEventDispatcherMock()); + + $message = new NullMessage(); + $message->setProperty('event_name', 'anEventName'); + + $result = $processor->process($message, new NullContext()); + + $this->assertInstanceOf(Result::class, $result); + $this->assertEquals(Result::REJECT, $result->getStatus()); + $this->assertEquals('The message is missing "transformer_name" property', $result->getReason()); + } + + public function testShouldDispatchAsyncListenersOnly() + { + $eventName = 'theEventName'; + $transformerName = 'theTransformerName'; + + $event = new GenericEvent(); + + $message = new NullMessage(); + $message->setProperty('event_name', $eventName); + $message->setProperty('transformer_name', $transformerName); + + $transformerMock = $this->createEventTransformerMock(); + $transformerMock + ->expects($this->once()) + ->method('toEvent') + ->with($eventName, $this->identicalTo($message)) + ->willReturn($event) + ; + + $registryMock = $this->createRegistryMock(); + $registryMock + ->expects($this->once()) + ->method('getTransformer') + ->with($transformerName) + ->willReturn($transformerMock) + ; + + $dispatcherMock = $this->createProxyEventDispatcherMock(); + $dispatcherMock + ->expects($this->once()) + ->method('dispatchAsyncListenersOnly') + ->with($eventName, $this->identicalTo($event)) + ; + $dispatcherMock + ->expects($this->never()) + ->method('dispatch') + ; + + $processor = new AsyncProcessor($registryMock, $dispatcherMock); + + $this->assertSame(Result::ACK, $processor->process($message, new NullContext())); + } + + /** + * @return MockObject|EventTransformer + */ + private function createEventTransformerMock() + { + return $this->createMock(EventTransformer::class); + } + + /** + * @return MockObject|AsyncEventDispatcher + */ + private function createProxyEventDispatcherMock() + { + return $this->createMock(AsyncEventDispatcher::class); + } + + /** + * @return MockObject|Registry + */ + private function createRegistryMock() + { + return $this->createMock(Registry::class); + } +} diff --git a/pkg/async-event-dispatcher/Tests/ContainerAwareRegistryTest.php b/pkg/async-event-dispatcher/Tests/ContainerAwareRegistryTest.php new file mode 100644 index 000000000..79762ac17 --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/ContainerAwareRegistryTest.php @@ -0,0 +1,102 @@ +assertClassImplements(Registry::class, ContainerAwareRegistry::class); + } + + public function testShouldAllowGetTransportNameByEventName() + { + $container = new Container(); + + $registry = new ContainerAwareRegistry([ + 'fooEvent' => 'fooTrans', + ], [], $container); + + $this->assertEquals('fooTrans', $registry->getTransformerNameForEvent('fooEvent')); + } + + public function testShouldAllowDefineTransportNameAsRegExpPattern() + { + $container = new Container(); + + $registry = new ContainerAwareRegistry([ + '/.*/' => 'fooRegExpTrans', + 'fooEvent' => 'fooTrans', + ], [], $container); + + // guard + $this->assertEquals('fooTrans', $registry->getTransformerNameForEvent('fooEvent')); + + $this->assertEquals('fooRegExpTrans', $registry->getTransformerNameForEvent('fooRegExpEvent')); + } + + public function testThrowIfNotSupportedEventGiven() + { + $container = new Container(); + + $registry = new ContainerAwareRegistry([ + 'fooEvent' => 'fooTrans', + ], [], $container); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no transformer registered for the given event fooNotSupportedEvent'); + $registry->getTransformerNameForEvent('fooNotSupportedEvent'); + } + + public function testThrowIfThereIsNoRegisteredTransformerWithSuchName() + { + $container = new Container(); + + $registry = new ContainerAwareRegistry([], [ + 'fooTrans' => 'foo_trans_id', + ], $container); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no transformer named fooNotRegisteredName'); + $registry->getTransformer('fooNotRegisteredName'); + } + + public function testThrowIfContainerReturnsServiceNotInstanceOfEventTransformer() + { + $container = new Container(); + $container->set('foo_trans_id', new \stdClass()); + + $registry = new ContainerAwareRegistry([], [ + 'fooTrans' => 'foo_trans_id', + ], $container); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The container must return instance of Enqueue\AsyncEventDispatcher\EventTransformer but got stdClass'); + $registry->getTransformer('fooTrans'); + } + + public function testShouldReturnEventTransformer() + { + $eventTransformerMock = $this->createMock(EventTransformer::class); + + $container = new Container(); + $container->set('foo_trans_id', $eventTransformerMock); + + $registry = new ContainerAwareRegistry([], [ + 'fooTrans' => 'foo_trans_id', + ], $container); + + $this->assertSame($eventTransformerMock, $registry->getTransformer('fooTrans')); + } +} diff --git a/pkg/async-event-dispatcher/Tests/Functional/UseCasesTest.php b/pkg/async-event-dispatcher/Tests/Functional/UseCasesTest.php new file mode 100644 index 000000000..169d8ea5b --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/Functional/UseCasesTest.php @@ -0,0 +1,214 @@ +remove(__DIR__.'/queues/'); + + // it could be any other queue-interop/queue-interop compatible context. + $this->context = $context = (new FsConnectionFactory('file://'.__DIR__.'/queues'))->createContext(); + $this->queue = $queue = $context->createQueue('symfony_events'); + + $registry = new SimpleRegistry( + [ + 'test_async' => 'test_async', + 'test_async_from_async' => 'test_async', + ], + [ + 'test_async' => new TestAsyncEventTransformer($context), + ]); + + $asyncListener = new AsyncListener($context, $registry, $queue); + $this->asyncListener = function ($event, $name, $dispatcher) use ($asyncListener) { + $asyncListener->onEvent($event, $name); + + $consumer = $this->context->createConsumer($this->queue); + + $message = $consumer->receiveNoWait(); + + if ($message) { + $consumer->reject($message, true); + + echo "Send message for event: $name\n"; + } + }; + + $this->dispatcher = $dispatcher = new EventDispatcher(); + + $this->asyncDispatcher = $asyncDispatcher = new AsyncEventDispatcher($dispatcher, $asyncListener); + + $this->asyncProcessor = new AsyncProcessor($registry, $asyncDispatcher); + } + + public function testShouldDispatchBothAsyncEventAndSyncOne() + { + $this->dispatcher->addListener('test_async', function () { + echo "Sync event\n"; + }); + + $this->dispatcher->addListener('test_async', $this->asyncListener); + + $this->asyncDispatcher->addListener('test_async', function ($event, $eventName) { + echo "Async event\n"; + }); + + $this->dispatch($this->dispatcher, new GenericEvent(), 'test_async'); + $this->processMessages(); + + $this->expectOutputString("Sync event\nSend message for event: test_async\nAsync event\n"); + } + + public function testShouldDispatchBothAsyncEventAndSyncOneFromWhenDispatchedFromInsideAnotherEvent() + { + $this->dispatcher->addListener('foo', function ($event, $name, EventDispatcherInterface $dispatcher) { + echo "Foo event\n"; + + $this->dispatch($dispatcher, new GenericEvent(), 'test_async'); + }); + + $this->dispatcher->addListener('test_async', function () { + echo "Sync event\n"; + }); + + $this->dispatcher->addListener('test_async', $this->asyncListener); + + $this->asyncDispatcher->addListener('test_async', function ($event, $eventName) { + echo "Async event\n"; + }); + + $this->dispatch($this->dispatcher, new GenericEvent(), 'foo'); + + $this->processMessages(); + + $this->expectOutputString("Foo event\nSync event\nSend message for event: test_async\nAsync event\n"); + } + + public function testShouldDispatchOtherAsyncEventFromAsyncEvent() + { + $this->dispatcher->addListener('test_async', $this->asyncListener); + $this->dispatcher->addListener('test_async_from_async', $this->asyncListener); + + $this->asyncDispatcher->addListener('test_async', function ($event, $eventName, EventDispatcherInterface $dispatcher) { + echo "Async event\n"; + + $this->dispatch($dispatcher, new GenericEvent(), 'test_async_from_async'); + }); + + $this->dispatcher->addListener('test_async_from_async', function ($event, $eventName, EventDispatcherInterface $dispatcher) { + echo "Async event from event\n"; + }); + + $this->dispatch($this->dispatcher, new GenericEvent(), 'test_async'); + + $this->processMessages(); + $this->processMessages(); + + $this->expectOutputString("Send message for event: test_async\nAsync event\nSend message for event: test_async_from_async\nAsync event from event\n"); + } + + public function testShouldDispatchSyncListenerIfDispatchedFromAsycListner() + { + $this->dispatcher->addListener('test_async', $this->asyncListener); + + $this->dispatcher->addListener('sync', function () { + echo "Sync event\n"; + }); + + $this->asyncDispatcher->addListener('test_async', function ($event, $eventName, EventDispatcherInterface $dispatcher) { + echo "Async event\n"; + + $this->dispatch($dispatcher, new GenericEvent(), 'sync'); + }); + + $this->dispatch($this->dispatcher, new GenericEvent(), 'test_async'); + + $this->processMessages(); + + $this->expectOutputString("Send message for event: test_async\nAsync event\nSync event\n"); + } + + private function dispatch(EventDispatcherInterface $dispatcher, $event, $eventName): void + { + if (!class_exists(Event::class)) { + // Symfony 5 + $dispatcher->dispatch($event, $eventName); + } else { + // Symfony < 5 + $dispatcher->dispatch($eventName, $event); + } + } + + private function processMessages() + { + $consumer = $this->context->createConsumer($this->queue); + if ($message = $consumer->receiveNoWait()) { + $result = $this->asyncProcessor->process($message, $this->context); + + switch ((string) $result) { + case Processor::ACK: + $consumer->acknowledge($message); + break; + case Processor::REJECT: + $consumer->reject($message); + break; + case Processor::REQUEUE: + $consumer->reject($message, true); + break; + default: + throw new \LogicException('Result is not supported'); + } + } + } +} diff --git a/pkg/async-event-dispatcher/Tests/PhpSerializerEventTransformerTest.php b/pkg/async-event-dispatcher/Tests/PhpSerializerEventTransformerTest.php new file mode 100644 index 000000000..498ca3ae9 --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/PhpSerializerEventTransformerTest.php @@ -0,0 +1,66 @@ +assertClassImplements(EventTransformer::class, PhpSerializerEventTransformer::class); + } + + public function testShouldReturnMessageWithPhpSerializedEventAsBodyOnToMessage() + { + $transformer = new PhpSerializerEventTransformer($this->createContextStub()); + + $event = new GenericEvent('theSubject'); + $expectedBody = serialize($event); + + $message = $transformer->toMessage('fooEvent', $event); + + $this->assertInstanceOf(Message::class, $message); + $this->assertEquals($expectedBody, $message->getBody()); + } + + public function testShouldReturnEventUnserializedFromMessageBodyOnToEvent() + { + $message = new NullMessage(); + $message->setBody(serialize(new GenericEvent('theSubject'))); + + $transformer = new PhpSerializerEventTransformer($this->createContextStub()); + + $event = $transformer->toEvent('anEventName', $message); + + $this->assertInstanceOf(GenericEvent::class, $event); + $this->assertEquals('theSubject', $event->getSubject()); + } + + /** + * @return MockObject|Context + */ + private function createContextStub() + { + $context = $this->createMock(Context::class); + $context + ->expects($this->any()) + ->method('createMessage') + ->willReturnCallback(function ($body) { + return new NullMessage($body); + }) + ; + + return $context; + } +} diff --git a/pkg/async-event-dispatcher/Tests/ProxyEventDispatcherTest.php b/pkg/async-event-dispatcher/Tests/ProxyEventDispatcherTest.php new file mode 100644 index 000000000..eed680aa6 --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/ProxyEventDispatcherTest.php @@ -0,0 +1,130 @@ +assertClassExtends(EventDispatcher::class, AsyncEventDispatcher::class); + } + + public function testShouldSetSyncModeForGivenEventNameOnDispatchAsyncListenersOnly() + { + $asyncListenerMock = $this->createAsyncListenerMock(); + $asyncListenerMock + ->expects($this->once()) + ->method('resetSyncMode') + ; + $asyncListenerMock + ->expects($this->once()) + ->method('syncMode') + ->with('theEvent') + ; + + $trueEventDispatcher = new EventDispatcher(); + $dispatcher = new AsyncEventDispatcher($trueEventDispatcher, $asyncListenerMock); + + $event = new GenericEvent(); + $dispatcher->dispatchAsyncListenersOnly('theEvent', $event); + } + + public function testShouldCallAsyncEventButNotOtherOnDispatchAsyncListenersOnly() + { + $otherEventWasCalled = false; + $trueEventDispatcher = new EventDispatcher(); + $trueEventDispatcher->addListener('theEvent', function () use (&$otherEventWasCalled) { + $this->assertInstanceOf(AsyncEventDispatcher::class, func_get_arg(2)); + + $otherEventWasCalled = true; + }); + + $asyncEventWasCalled = false; + $dispatcher = new AsyncEventDispatcher($trueEventDispatcher, $this->createAsyncListenerMock()); + $dispatcher->addListener('theEvent', function () use (&$asyncEventWasCalled) { + $this->assertInstanceOf(AsyncEventDispatcher::class, func_get_arg(2)); + + $asyncEventWasCalled = true; + }); + + $event = new GenericEvent(); + $dispatcher->dispatchAsyncListenersOnly('theEvent', $event); + + $this->assertFalse($otherEventWasCalled); + $this->assertTrue($asyncEventWasCalled); + } + + public function testShouldCallOtherEventIfDispatchedFromAsyncEventOnDispatchAsyncListenersOnly() + { + $otherEventWasCalled = false; + $trueEventDispatcher = new EventDispatcher(); + $trueEventDispatcher->addListener('theOtherEvent', function () use (&$otherEventWasCalled) { + $this->assertNotInstanceOf(AsyncEventDispatcher::class, func_get_arg(2)); + + $otherEventWasCalled = true; + }); + + $asyncEventWasCalled = false; + $dispatcher = new AsyncEventDispatcher($trueEventDispatcher, $this->createAsyncListenerMock()); + $dispatcher->addListener('theEvent', function () use (&$asyncEventWasCalled) { + $this->assertInstanceOf(AsyncEventDispatcher::class, func_get_arg(2)); + + $asyncEventWasCalled = true; + + if (!class_exists(Event::class)) { + // Symfony 5 + func_get_arg(2)->dispatch(func_get_arg(0), 'theOtherEvent'); + } else { + // Symfony < 5 + func_get_arg(2)->dispatch('theOtherEvent'); + } + }); + + $event = new GenericEvent(); + $dispatcher->dispatchAsyncListenersOnly('theEvent', $event); + + $this->assertTrue($otherEventWasCalled); + $this->assertTrue($asyncEventWasCalled); + } + + public function testShouldNotCallAsyncEventIfDispatchedFromOtherEventOnDispatchAsyncListenersOnly() + { + $trueEventDispatcher = new EventDispatcher(); + $trueEventDispatcher->addListener('theOtherEvent', function () { + func_get_arg(2)->dispatch('theOtherAsyncEvent'); + }); + + $dispatcher = new AsyncEventDispatcher($trueEventDispatcher, $this->createAsyncListenerMock()); + $dispatcher->addListener('theAsyncEvent', function () { + func_get_arg(2)->dispatch('theOtherEvent'); + }); + $asyncEventWasCalled = false; + $dispatcher->addListener('theOtherAsyncEvent', function () use (&$asyncEventWasCalled) { + $asyncEventWasCalled = true; + }); + + $event = new GenericEvent(); + $dispatcher->dispatchAsyncListenersOnly('theEvent', $event); + + $this->assertFalse($asyncEventWasCalled); + } + + /** + * @return MockObject|AsyncListener + */ + private function createAsyncListenerMock() + { + return $this->createMock(AsyncListener::class); + } +} diff --git a/pkg/async-event-dispatcher/Tests/SimpleRegistryTest.php b/pkg/async-event-dispatcher/Tests/SimpleRegistryTest.php new file mode 100644 index 000000000..c144e7466 --- /dev/null +++ b/pkg/async-event-dispatcher/Tests/SimpleRegistryTest.php @@ -0,0 +1,92 @@ +assertClassImplements(Registry::class, SimpleRegistry::class); + } + + public function testShouldAllowGetTransportNameByEventName() + { + $registry = new SimpleRegistry([ + 'fooEvent' => 'fooTrans', + ], []); + + $this->assertEquals('fooTrans', $registry->getTransformerNameForEvent('fooEvent')); + } + + public function testShouldAllowDefineTransportNameAsRegExpPattern() + { + $registry = new SimpleRegistry([ + '/.*/' => 'fooRegExpTrans', + 'fooEvent' => 'fooTrans', + ], []); + + // guard + $this->assertEquals('fooTrans', $registry->getTransformerNameForEvent('fooEvent')); + + $this->assertEquals('fooRegExpTrans', $registry->getTransformerNameForEvent('fooRegExpEvent')); + } + + public function testThrowIfNotSupportedEventGiven() + { + $registry = new SimpleRegistry([ + 'fooEvent' => 'fooTrans', + ], []); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no transformer registered for the given event fooNotSupportedEvent'); + $registry->getTransformerNameForEvent('fooNotSupportedEvent'); + } + + public function testThrowIfThereIsNoRegisteredTransformerWithSuchName() + { + $registry = new SimpleRegistry([], [ + 'fooTrans' => 'foo_trans_id', + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no transformer named fooNotRegisteredName'); + $registry->getTransformer('fooNotRegisteredName'); + } + + public function testThrowIfObjectAssocWithTransportNameNotInstanceOfEventTransformer() + { + $container = new Container(); + $container->set('foo_trans_id', new \stdClass()); + + $registry = new SimpleRegistry([], [ + 'fooTrans' => new \stdClass(), + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The container must return instance of Enqueue\AsyncEventDispatcher\EventTransformer but got stdClass'); + $registry->getTransformer('fooTrans'); + } + + public function testShouldReturnEventTransformer() + { + $eventTransformerMock = $this->createMock(EventTransformer::class); + + $container = new Container(); + $container->set('foo_trans_id', $eventTransformerMock); + + $registry = new SimpleRegistry([], [ + 'fooTrans' => $eventTransformerMock, + ]); + + $this->assertSame($eventTransformerMock, $registry->getTransformer('fooTrans')); + } +} diff --git a/pkg/async-event-dispatcher/composer.json b/pkg/async-event-dispatcher/composer.json new file mode 100644 index 000000000..f78597af4 --- /dev/null +++ b/pkg/async-event-dispatcher/composer.json @@ -0,0 +1,46 @@ +{ + "name": "enqueue/async-event-dispatcher", + "type": "library", + "description": "Symfony async event dispatcher", + "keywords": ["messaging", "queue", "async event", "event dispatcher"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "enqueue/enqueue": "^0.10", + "queue-interop/queue-interop": "^0.8", + "symfony/event-dispatcher": "^5.4|^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0", + "enqueue/null": "0.10.x-dev", + "enqueue/fs": "0.10.x-dev", + "enqueue/test": "0.10.x-dev" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "suggest": { + "symfony/dependency-injection": "^5.4|^6.0 If you'd like to use async event dispatcher container extension." + }, + "autoload": { + "psr-4": { "Enqueue\\AsyncEventDispatcher\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/async-event-dispatcher/phpunit.xml.dist b/pkg/async-event-dispatcher/phpunit.xml.dist new file mode 100644 index 000000000..e5c3f6d2d --- /dev/null +++ b/pkg/async-event-dispatcher/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Resources + ./Tests + + + + diff --git a/pkg/dbal/.gitattributes b/pkg/dbal/.gitattributes new file mode 100644 index 000000000..3fab2dac1 --- /dev/null +++ b/pkg/dbal/.gitattributes @@ -0,0 +1,6 @@ +/examples export-ignore +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/dbal/.github/workflows/ci.yml b/pkg/dbal/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/dbal/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/dbal/.gitignore b/pkg/dbal/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/dbal/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/dbal/DbalConnectionFactory.php b/pkg/dbal/DbalConnectionFactory.php new file mode 100644 index 000000000..305375a89 --- /dev/null +++ b/pkg/dbal/DbalConnectionFactory.php @@ -0,0 +1,145 @@ + [] - dbal connection options. see http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html + * 'table_name' => 'enqueue', - database table name. + * 'polling_interval' => '1000', - How often query for new messages (milliseconds) + * 'lazy' => true, - Use lazy database connection (boolean) + * ] + * + * or + * + * mysql://user:pass@localhost:3606/db?charset=UTF-8 + * + * @param array|string|null $config + */ + public function __construct($config = 'mysql:') + { + if (empty($config)) { + $config = $this->parseDsn('mysql:'); + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace_recursive($config, $this->parseDsn($config['dsn'], $config)); + unset($config['dsn']); + } + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace_recursive([ + 'connection' => [], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], $config); + } + + /** + * @return DbalContext + */ + public function createContext(): Context + { + if ($this->config['lazy']) { + return new DbalContext(function () { + return $this->establishConnection(); + }, $this->config); + } + + return new DbalContext($this->establishConnection(), $this->config); + } + + public function close(): void + { + if ($this->connection) { + $this->connection->close(); + } + } + + private function establishConnection(): Connection + { + if (false == $this->connection) { + $this->connection = DriverManager::getConnection($this->config['connection']); + $this->connection->connect(); + } + + return $this->connection; + } + + private function parseDsn(string $dsn, ?array $config = null): array + { + $parsedDsn = Dsn::parseFirst($dsn); + + $supported = [ + 'db2' => 'db2', + 'ibm-db2' => 'ibm-db2', + 'mssql' => 'mssql', + 'sqlsrv+pdo' => 'pdo_sqlsrv', + 'mysql' => 'mysql', + 'mysql2' => 'mysql2', + 'mysql+pdo' => 'pdo_mysql', + 'pgsql' => 'pgsql', + 'postgres' => 'postgres', + 'pgsql+pdo' => 'pdo_pgsql', + 'sqlite' => 'sqlite', + 'sqlite3' => 'sqlite3', + 'sqlite+pdo' => 'pdo_sqlite', + ]; + + if ($parsedDsn && false == isset($supported[$parsedDsn->getScheme()])) { + throw new \LogicException(sprintf('The given DSN schema "%s" is not supported. There are supported schemes: "%s".', $parsedDsn->getScheme(), implode('", "', array_keys($supported)))); + } + + $doctrineScheme = $supported[$parsedDsn->getScheme()]; + $dsnHasProtocolOnly = $parsedDsn->getScheme().':' === $dsn; + if ($dsnHasProtocolOnly && is_array($config) && array_key_exists('connection', $config)) { + $default = [ + 'driver' => $doctrineScheme, + 'host' => 'localhost', + 'port' => '3306', + 'user' => 'root', + 'password' => '', + ]; + + return [ + 'lazy' => true, + 'connection' => array_replace_recursive($default, $config['connection']), + ]; + } + + return [ + 'lazy' => true, + 'connection' => [ + 'url' => $dsnHasProtocolOnly ? + $doctrineScheme.'://root@localhost' : + str_replace($parsedDsn->getScheme(), $doctrineScheme, $dsn), + ], + ]; + } +} diff --git a/pkg/dbal/DbalConsumer.php b/pkg/dbal/DbalConsumer.php new file mode 100644 index 000000000..f1f397441 --- /dev/null +++ b/pkg/dbal/DbalConsumer.php @@ -0,0 +1,122 @@ +context = $context; + $this->queue = $queue; + $this->dbal = $this->context->getDbalConnection(); + + $this->redeliveryDelay = 1200000; + } + + /** + * Get interval between retry failed messages in milliseconds. + */ + public function getRedeliveryDelay(): int + { + return $this->redeliveryDelay; + } + + /** + * Get interval between retrying failed messages in milliseconds. + */ + public function setRedeliveryDelay(int $redeliveryDelay): self + { + $this->redeliveryDelay = $redeliveryDelay; + + return $this; + } + + /** + * @return DbalDestination + */ + public function getQueue(): Queue + { + return $this->queue; + } + + public function receiveNoWait(): ?Message + { + $redeliveryDelay = $this->getRedeliveryDelay() / 1000; // milliseconds to seconds + + $this->removeExpiredMessages(); + $this->redeliverMessages(); + + return $this->fetchMessage([$this->queue->getQueueName()], $redeliveryDelay); + } + + /** + * @param DbalMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, DbalMessage::class); + + $this->deleteMessage($message->getDeliveryId()); + } + + /** + * @param DbalMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, DbalMessage::class); + + if ($requeue) { + $message = clone $message; + $message->setRedelivered(false); + + $this->getContext()->createProducer()->send($this->queue, $message); + } + + $this->acknowledge($message); + } + + protected function getContext(): DbalContext + { + return $this->context; + } + + protected function getConnection(): Connection + { + return $this->dbal; + } +} diff --git a/pkg/dbal/DbalConsumerHelperTrait.php b/pkg/dbal/DbalConsumerHelperTrait.php new file mode 100644 index 000000000..617f37ba0 --- /dev/null +++ b/pkg/dbal/DbalConsumerHelperTrait.php @@ -0,0 +1,158 @@ +getConnection()->createQueryBuilder() + ->select('id') + ->from($this->getContext()->getTableName()) + ->andWhere('queue IN (:queues)') + ->andWhere('delayed_until IS NULL OR delayed_until <= :delayedUntil') + ->andWhere('delivery_id IS NULL') + ->addOrderBy('priority', 'asc') + ->addOrderBy('published_at', 'asc') + ->setParameter('queues', $queues, Connection::PARAM_STR_ARRAY) + ->setParameter('delayedUntil', $now, DbalType::INTEGER) + ->setMaxResults(1); + + $update = $this->getConnection()->createQueryBuilder() + ->update($this->getContext()->getTableName()) + ->set('delivery_id', ':deliveryId') + ->set('redeliver_after', ':redeliverAfter') + ->andWhere('id = :messageId') + ->andWhere('delivery_id IS NULL') + ->setParameter('deliveryId', $deliveryId, DbalType::GUID) + ->setParameter('redeliverAfter', $now + $redeliveryDelay, DbalType::BIGINT) + ; + + while (microtime(true) < $endAt) { + try { + $result = $select->execute()->fetchAssociative(); + if (empty($result)) { + return null; + } + + $update + ->setParameter('messageId', $result['id'], DbalType::GUID); + + if ($update->execute()) { + $deliveredMessage = $this->getConnection()->createQueryBuilder() + ->select('*') + ->from($this->getContext()->getTableName()) + ->andWhere('delivery_id = :deliveryId') + ->setParameter('deliveryId', $deliveryId, DbalType::GUID) + ->setMaxResults(1) + ->execute() + ->fetchAssociative(); + + // the message has been removed by a 3rd party, such as truncate operation. + if (false === $deliveredMessage) { + continue; + } + + if ($deliveredMessage['redelivered'] || empty($deliveredMessage['time_to_live']) || $deliveredMessage['time_to_live'] > time()) { + return $this->getContext()->convertMessage($deliveredMessage); + } + } + } catch (RetryableException $e) { + // maybe next time we'll get more luck + } + } + + return null; + } + + protected function redeliverMessages(): void + { + if (null === $this->redeliverMessagesLastExecutedAt) { + $this->redeliverMessagesLastExecutedAt = microtime(true); + } elseif ((microtime(true) - $this->redeliverMessagesLastExecutedAt) < 1) { + return; + } + + $update = $this->getConnection()->createQueryBuilder() + ->update($this->getContext()->getTableName()) + ->set('delivery_id', ':deliveryId') + ->set('redelivered', ':redelivered') + ->andWhere('redeliver_after < :now') + ->andWhere('delivery_id IS NOT NULL') + ->setParameter('now', time(), DbalType::BIGINT) + ->setParameter('deliveryId', null, DbalType::GUID) + ->setParameter('redelivered', true, DbalType::BOOLEAN) + ; + + try { + $update->execute(); + + $this->redeliverMessagesLastExecutedAt = microtime(true); + } catch (RetryableException $e) { + // maybe next time we'll get more luck + } + } + + protected function removeExpiredMessages(): void + { + if (null === $this->removeExpiredMessagesLastExecutedAt) { + $this->removeExpiredMessagesLastExecutedAt = microtime(true); + } elseif ((microtime(true) - $this->removeExpiredMessagesLastExecutedAt) < 1) { + return; + } + + $delete = $this->getConnection()->createQueryBuilder() + ->delete($this->getContext()->getTableName()) + ->andWhere('(time_to_live IS NOT NULL) AND (time_to_live < :now)') + ->andWhere('delivery_id IS NULL') + ->andWhere('redelivered = :redelivered') + + ->setParameter('now', time(), DbalType::BIGINT) + ->setParameter('redelivered', false, DbalType::BOOLEAN) + ; + + try { + $delete->execute(); + } catch (RetryableException $e) { + // maybe next time we'll get more luck + } + + $this->removeExpiredMessagesLastExecutedAt = microtime(true); + } + + private function deleteMessage(string $deliveryId): void + { + if (empty($deliveryId)) { + throw new \LogicException(sprintf('Expected record was removed but it is not. Delivery id: "%s"', $deliveryId)); + } + + $this->getConnection()->delete( + $this->getContext()->getTableName(), + ['delivery_id' => $deliveryId], + ['delivery_id' => DbalType::GUID] + ); + } +} diff --git a/pkg/dbal/DbalContext.php b/pkg/dbal/DbalContext.php new file mode 100644 index 000000000..869dd67b8 --- /dev/null +++ b/pkg/dbal/DbalContext.php @@ -0,0 +1,242 @@ +config = array_replace([ + 'table_name' => 'enqueue', + 'polling_interval' => null, + 'subscription_polling_interval' => null, + ], $config); + + if ($connection instanceof Connection) { + $this->connection = $connection; + } elseif (is_callable($connection)) { + $this->connectionFactory = $connection; + } else { + throw new \InvalidArgumentException(sprintf('The connection argument must be either %s or callable that returns %s.', Connection::class, Connection::class)); + } + } + + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + $message = new DbalMessage(); + $message->setBody($body); + $message->setProperties($properties); + $message->setHeaders($headers); + + return $message; + } + + /** + * @return DbalDestination + */ + public function createQueue(string $name): Queue + { + return new DbalDestination($name); + } + + /** + * @return DbalDestination + */ + public function createTopic(string $name): Topic + { + return new DbalDestination($name); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return DbalProducer + */ + public function createProducer(): Producer + { + return new DbalProducer($this); + } + + /** + * @return DbalConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, DbalDestination::class); + + $consumer = new DbalConsumer($this, $destination); + + if (isset($this->config['polling_interval'])) { + $consumer->setPollingInterval((int) $this->config['polling_interval']); + } + + if (isset($this->config['redelivery_delay'])) { + $consumer->setRedeliveryDelay((int) $this->config['redelivery_delay']); + } + + return $consumer; + } + + public function close(): void + { + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + $consumer = new DbalSubscriptionConsumer($this); + + if (isset($this->config['redelivery_delay'])) { + $consumer->setRedeliveryDelay($this->config['redelivery_delay']); + } + + if (isset($this->config['subscription_polling_interval'])) { + $consumer->setPollingInterval($this->config['subscription_polling_interval']); + } + + return $consumer; + } + + /** + * @internal It must be used here and in the consumer only + */ + public function convertMessage(array $arrayMessage): DbalMessage + { + /** @var DbalMessage $message */ + $message = $this->createMessage( + $arrayMessage['body'], + $arrayMessage['properties'] ? JSON::decode($arrayMessage['properties']) : [], + $arrayMessage['headers'] ? JSON::decode($arrayMessage['headers']) : [] + ); + + if (isset($arrayMessage['id'])) { + $message->setMessageId($arrayMessage['id']); + } + if (isset($arrayMessage['queue'])) { + $message->setQueue($arrayMessage['queue']); + } + if (isset($arrayMessage['redelivered'])) { + $message->setRedelivered((bool) $arrayMessage['redelivered']); + } + if (isset($arrayMessage['priority'])) { + $message->setPriority((int) (-1 * $arrayMessage['priority'])); + } + if (isset($arrayMessage['published_at'])) { + $message->setPublishedAt((int) $arrayMessage['published_at']); + } + if (isset($arrayMessage['delivery_id'])) { + $message->setDeliveryId($arrayMessage['delivery_id']); + } + if (isset($arrayMessage['redeliver_after'])) { + $message->setRedeliverAfter((int) $arrayMessage['redeliver_after']); + } + + return $message; + } + + /** + * @param DbalDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + $this->getDbalConnection()->delete( + $this->getTableName(), + ['queue' => $queue->getQueueName()], + ['queue' => DbalType::STRING] + ); + } + + public function getTableName(): string + { + return $this->config['table_name']; + } + + public function getConfig(): array + { + return $this->config; + } + + public function getDbalConnection(): Connection + { + if (false == $this->connection) { + $connection = call_user_func($this->connectionFactory); + if (false == $connection instanceof Connection) { + throw new \LogicException(sprintf('The factory must return instance of Doctrine\DBAL\Connection. It returns %s', is_object($connection) ? $connection::class : gettype($connection))); + } + + $this->connection = $connection; + } + + return $this->connection; + } + + public function createDataBaseTable(): void + { + $sm = $this->getDbalConnection()->getSchemaManager(); + + if ($sm->tablesExist([$this->getTableName()])) { + return; + } + + $table = new Table($this->getTableName()); + + $table->addColumn('id', DbalType::GUID, ['length' => 16, 'fixed' => true]); + $table->addColumn('published_at', DbalType::BIGINT); + $table->addColumn('body', DbalType::TEXT, ['notnull' => false]); + $table->addColumn('headers', DbalType::TEXT, ['notnull' => false]); + $table->addColumn('properties', DbalType::TEXT, ['notnull' => false]); + $table->addColumn('redelivered', DbalType::BOOLEAN, ['notnull' => false]); + $table->addColumn('queue', DbalType::STRING); + $table->addColumn('priority', DbalType::SMALLINT, ['notnull' => false]); + $table->addColumn('delayed_until', DbalType::BIGINT, ['notnull' => false]); + $table->addColumn('time_to_live', DbalType::BIGINT, ['notnull' => false]); + $table->addColumn('delivery_id', DbalType::GUID, ['length' => 16, 'fixed' => true, 'notnull' => false]); + $table->addColumn('redeliver_after', DbalType::BIGINT, ['notnull' => false]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['priority', 'published_at', 'queue', 'delivery_id', 'delayed_until', 'id']); + + $table->addIndex(['redeliver_after', 'delivery_id']); + $table->addIndex(['time_to_live', 'delivery_id']); + $table->addIndex(['delivery_id']); + + $sm->createTable($table); + } +} diff --git a/pkg/dbal/DbalDestination.php b/pkg/dbal/DbalDestination.php new file mode 100644 index 000000000..793bc40e7 --- /dev/null +++ b/pkg/dbal/DbalDestination.php @@ -0,0 +1,31 @@ +destinationName = $name; + } + + public function getQueueName(): string + { + return $this->destinationName; + } + + public function getTopicName(): string + { + return $this->destinationName; + } +} diff --git a/pkg/dbal/DbalMessage.php b/pkg/dbal/DbalMessage.php new file mode 100644 index 000000000..2485f0691 --- /dev/null +++ b/pkg/dbal/DbalMessage.php @@ -0,0 +1,259 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + $this->priority = null; + $this->deliveryDelay = null; + $this->deliveryId = null; + $this->redeliverAfter = null; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function getPriority(): ?int + { + return $this->priority; + } + + public function setPriority(?int $priority = null): void + { + $this->priority = $priority; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * Set delay in milliseconds. + */ + public function setDeliveryDelay(?int $deliveryDelay = null): void + { + $this->deliveryDelay = $deliveryDelay; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } + + /** + * Set time to live in milliseconds. + */ + public function setTimeToLive(?int $timeToLive = null): void + { + $this->timeToLive = $timeToLive; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id', null); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id', null); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function getDeliveryId(): ?string + { + return $this->deliveryId; + } + + public function setDeliveryId(?string $deliveryId = null): void + { + $this->deliveryId = $deliveryId; + } + + public function getRedeliverAfter(): int + { + return $this->redeliverAfter; + } + + public function setRedeliverAfter(?int $redeliverAfter = null): void + { + $this->redeliverAfter = $redeliverAfter; + } + + public function getPublishedAt(): ?int + { + return $this->publishedAt; + } + + public function setPublishedAt(?int $publishedAt = null): void + { + $this->publishedAt = $publishedAt; + } + + public function getQueue(): ?string + { + return $this->queue; + } + + public function setQueue(?string $queue): void + { + $this->queue = $queue; + } +} diff --git a/pkg/dbal/DbalProducer.php b/pkg/dbal/DbalProducer.php new file mode 100644 index 000000000..9e3c203dd --- /dev/null +++ b/pkg/dbal/DbalProducer.php @@ -0,0 +1,166 @@ +context = $context; + } + + /** + * @param DbalDestination $destination + * @param DbalMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, DbalDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, DbalMessage::class); + + if (null !== $this->priority && null === $message->getPriority()) { + $message->setPriority($this->priority); + } + if (null !== $this->deliveryDelay && null === $message->getDeliveryDelay()) { + $message->setDeliveryDelay($this->deliveryDelay); + } + if (null !== $this->timeToLive && null === $message->getTimeToLive()) { + $message->setTimeToLive($this->timeToLive); + } + + $body = $message->getBody(); + + $publishedAt = null !== $message->getPublishedAt() ? + $message->getPublishedAt() : + (int) (microtime(true) * 10000) + ; + + $dbalMessage = [ + 'id' => Uuid::uuid4(), + 'published_at' => $publishedAt, + 'body' => $body, + 'headers' => JSON::encode($message->getHeaders()), + 'properties' => JSON::encode($message->getProperties()), + 'priority' => -1 * $message->getPriority(), + 'queue' => $destination->getQueueName(), + 'redelivered' => false, + 'delivery_id' => null, + 'redeliver_after' => null, + ]; + + $delay = $message->getDeliveryDelay(); + if ($delay) { + if (!is_int($delay)) { + throw new \LogicException(sprintf('Delay must be integer but got: "%s"', is_object($delay) ? $delay::class : gettype($delay))); + } + + if ($delay <= 0) { + throw new \LogicException(sprintf('Delay must be positive integer but got: "%s"', $delay)); + } + + $dbalMessage['delayed_until'] = time() + (int) ($delay / 1000); + } + + $timeToLive = $message->getTimeToLive(); + if ($timeToLive) { + if (!is_int($timeToLive)) { + throw new \LogicException(sprintf('TimeToLive must be integer but got: "%s"', is_object($timeToLive) ? $timeToLive::class : gettype($timeToLive))); + } + + if ($timeToLive <= 0) { + throw new \LogicException(sprintf('TimeToLive must be positive integer but got: "%s"', $timeToLive)); + } + + $dbalMessage['time_to_live'] = time() + (int) ($timeToLive / 1000); + } + + try { + $rowsAffected = $this->context->getDbalConnection()->insert($this->context->getTableName(), $dbalMessage, [ + 'id' => DbalType::GUID, + 'published_at' => DbalType::INTEGER, + 'body' => DbalType::TEXT, + 'headers' => DbalType::TEXT, + 'properties' => DbalType::TEXT, + 'priority' => DbalType::SMALLINT, + 'queue' => DbalType::STRING, + 'time_to_live' => DbalType::INTEGER, + 'delayed_until' => DbalType::INTEGER, + 'redelivered' => DbalType::SMALLINT, + 'delivery_id' => DbalType::STRING, + 'redeliver_after' => DbalType::BIGINT, + ]); + + if (1 !== $rowsAffected) { + throw new Exception('The message was not enqueued. Dbal did not confirm that the record is inserted.'); + } + } catch (\Exception $e) { + throw new Exception('The transport fails to send the message due to some internal error.', 0, $e); + } + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/pkg/dbal/DbalSubscriptionConsumer.php b/pkg/dbal/DbalSubscriptionConsumer.php new file mode 100644 index 000000000..472fdfcb4 --- /dev/null +++ b/pkg/dbal/DbalSubscriptionConsumer.php @@ -0,0 +1,193 @@ +context = $context; + $this->dbal = $this->context->getDbalConnection(); + $this->subscribers = []; + + $this->redeliveryDelay = 1200000; + } + + /** + * Get interval between retrying failed messages in milliseconds. + */ + public function getRedeliveryDelay(): int + { + return $this->redeliveryDelay; + } + + public function setRedeliveryDelay(int $redeliveryDelay): self + { + $this->redeliveryDelay = $redeliveryDelay; + + return $this; + } + + public function getPollingInterval(): int + { + return $this->pollingInterval; + } + + public function setPollingInterval(int $msec): self + { + $this->pollingInterval = $msec; + + return $this; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('No subscribers'); + } + + $queueNames = []; + foreach (array_keys($this->subscribers) as $queueName) { + $queueNames[$queueName] = $queueName; + } + + $timeout /= 1000; + $now = time(); + $redeliveryDelay = $this->getRedeliveryDelay() / 1000; // milliseconds to seconds + + $currentQueueNames = []; + $queueConsumed = false; + while (true) { + if (empty($currentQueueNames)) { + $currentQueueNames = $queueNames; + $queueConsumed = false; + } + + $this->removeExpiredMessages(); + $this->redeliverMessages(); + + if ($message = $this->fetchMessage($currentQueueNames, $redeliveryDelay)) { + $queueConsumed = true; + + /** + * @var DbalConsumer $consumer + * @var callable $callback + */ + [$consumer, $callback] = $this->subscribers[$message->getQueue()]; + + if (false === call_user_func($callback, $message, $consumer)) { + return; + } + + unset($currentQueueNames[$message->getQueue()]); + } else { + $currentQueueNames = []; + + if (!$queueConsumed) { + usleep($this->getPollingInterval() * 1000); + } + } + + if ($timeout && microtime(true) >= $now + $timeout) { + return; + } + } + } + + /** + * @param DbalConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof DbalConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', DbalConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + if (array_key_exists($queueName, $this->subscribers)) { + if ($this->subscribers[$queueName][0] === $consumer && $this->subscribers[$queueName][1] === $callback) { + return; + } + + throw new \InvalidArgumentException(sprintf('There is a consumer subscribed to queue: "%s"', $queueName)); + } + + $this->subscribers[$queueName] = [$consumer, $callback]; + } + + /** + * @param DbalConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof DbalConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', DbalConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + + if (false == array_key_exists($queueName, $this->subscribers)) { + return; + } + + if ($this->subscribers[$queueName][0] !== $consumer) { + return; + } + + unset($this->subscribers[$queueName]); + } + + public function unsubscribeAll(): void + { + $this->subscribers = []; + } + + protected function getContext(): DbalContext + { + return $this->context; + } + + protected function getConnection(): Connection + { + return $this->dbal; + } +} diff --git a/pkg/dbal/DbalType.php b/pkg/dbal/DbalType.php new file mode 100644 index 000000000..38a14381f --- /dev/null +++ b/pkg/dbal/DbalType.php @@ -0,0 +1,34 @@ + null, - doctrine dbal connection name + * 'table_name' => 'enqueue', - database table name. + * 'polling_interval' => 1000, - How often query for new messages (milliseconds) + * 'lazy' => true, - Use lazy database connection (boolean) + * ]. + */ + public function __construct(ManagerRegistry $registry, array $config = []) + { + $this->config = array_replace([ + 'connection_name' => null, + 'lazy' => true, + ], $config); + + $this->registry = $registry; + } + + /** + * @return DbalContext + */ + public function createContext(): Context + { + if ($this->config['lazy']) { + return new DbalContext(function () { + return $this->establishConnection(); + }, $this->config); + } + + return new DbalContext($this->establishConnection(), $this->config); + } + + public function close(): void + { + } + + private function establishConnection(): Connection + { + $connection = $this->registry->getConnection($this->config['connection_name']); + $connection->connect(); + + return $connection; + } +} diff --git a/pkg/dbal/README.md b/pkg/dbal/README.md new file mode 100644 index 000000000..97ed98367 --- /dev/null +++ b/pkg/dbal/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Doctrine DBAL Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/dbal/ci.yml?branch=master)](https://github.com/php-enqueue/dbal/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/dbal/d/total.png)](https://packagist.org/packages/enqueue/dbal) +[![Latest Stable Version](https://poser.pugx.org/enqueue/dbal/version.png)](https://packagist.org/packages/enqueue/dbal) + +This is an implementation of Queue Interop specification. It allows you to send and consume message through Doctrine DBAL library and SQL like database as broker. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/dbal/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/dbal/Tests/DbalConnectionFactoryConfigTest.php b/pkg/dbal/Tests/DbalConnectionFactoryConfigTest.php new file mode 100644 index 000000000..5929e1479 --- /dev/null +++ b/pkg/dbal/Tests/DbalConnectionFactoryConfigTest.php @@ -0,0 +1,231 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new DbalConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotSupported() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN schema "http" is not supported. There are supported schemes: "db2", "ibm-db2", "mssql", "sqlsrv+pdo", "mysql", "mysql2", "mysql+pdo", "pgsql", "postgres", "pgsql+pdo", "sqlite", "sqlite3", "sqlite+pdo".'); + + new DbalConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid. It does not have scheme separator ":".'); + + new DbalConnectionFactory('invalidDSN'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new DbalConnectionFactory($config); + + $actualConfig = $this->readAttribute($factory, 'config'); + $this->assertSame($expectedConfig, $actualConfig); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'connection' => [ + 'url' => 'mysql://root@localhost', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + 'mysql:', + [ + 'connection' => [ + 'url' => 'mysql://root@localhost', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + 'mysql+pdo:', + [ + 'connection' => [ + 'url' => 'pdo_mysql://root@localhost', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + 'pgsql:', + [ + 'connection' => [ + 'url' => 'pgsql://root@localhost', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + [ + 'dsn' => 'mysql+pdo:', + 'connection' => [ + 'dbname' => 'customDbName', + ], + ], + [ + 'connection' => [ + 'dbname' => 'customDbName', + 'driver' => 'pdo_mysql', + 'host' => 'localhost', + 'port' => '3306', + 'user' => 'root', + 'password' => '', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + [ + 'dsn' => 'mysql+pdo:', + 'connection' => [ + 'dbname' => 'customDbName', + 'host' => 'host', + 'port' => '10000', + 'user' => 'user', + 'password' => 'pass', + ], + ], + [ + 'connection' => [ + 'dbname' => 'customDbName', + 'host' => 'host', + 'port' => '10000', + 'user' => 'user', + 'password' => 'pass', + 'driver' => 'pdo_mysql', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + [ + 'dsn' => 'mysql+pdo://user:pass@host:10000/db', + 'connection' => [ + 'foo' => 'fooValue', + ], + ], + [ + 'connection' => [ + 'foo' => 'fooValue', + 'url' => 'pdo_mysql://user:pass@host:10000/db', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + 'mysql://user:pass@host:10000/db', + [ + 'connection' => [ + 'url' => 'mysql://user:pass@host:10000/db', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + 'mysql+pdo://user:pass@host:10001/db', + [ + 'connection' => [ + 'url' => 'pdo_mysql://user:pass@host:10001/db', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + [], + [ + 'connection' => [ + 'url' => 'mysql://root@localhost', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + [ + 'connection' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'table_name' => 'a_queue_table', + ], + [ + 'connection' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'table_name' => 'a_queue_table', + 'polling_interval' => 1000, + 'lazy' => true, + ], + ]; + + yield [ + ['dsn' => 'mysql+pdo://user:pass@host:10001/db', 'foo' => 'fooVal'], + [ + 'connection' => [ + 'url' => 'pdo_mysql://user:pass@host:10001/db', + ], + 'table_name' => 'enqueue', + 'polling_interval' => 1000, + 'lazy' => true, + 'foo' => 'fooVal', + ], + ]; + } +} diff --git a/pkg/dbal/Tests/DbalConnectionFactoryTest.php b/pkg/dbal/Tests/DbalConnectionFactoryTest.php new file mode 100644 index 000000000..105466c26 --- /dev/null +++ b/pkg/dbal/Tests/DbalConnectionFactoryTest.php @@ -0,0 +1,61 @@ +assertClassImplements(ConnectionFactory::class, DbalConnectionFactory::class); + } + + public function testShouldCreateLazyContext() + { + $factory = new DbalConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(DbalContext::class, $context); + + $this->assertAttributeEquals(null, 'connection', $context); + $this->assertIsCallable($this->readAttribute($context, 'connectionFactory')); + } + + public function testShouldParseGenericDSN() + { + $factory = new DbalConnectionFactory('pgsql+pdo://foo@bar'); + + $context = $factory->createContext(); + + $this->assertInstanceOf(DbalContext::class, $context); + + $config = $context->getConfig(); + $this->assertArrayHasKey('connection', $config); + $this->assertArrayHasKey('url', $config['connection']); + $this->assertEquals('pdo_pgsql://foo@bar', $config['connection']['url']); + } + + public function testShouldParseSqliteAbsolutePathDSN() + { + $factory = new DbalConnectionFactory('sqlite+pdo:////tmp/some.sq3'); + + $context = $factory->createContext(); + + $this->assertInstanceOf(DbalContext::class, $context); + + $config = $context->getConfig(); + $this->assertArrayHasKey('connection', $config); + $this->assertArrayHasKey('url', $config['connection']); + $this->assertEquals('pdo_sqlite:////tmp/some.sq3', $config['connection']['url']); + } +} diff --git a/pkg/dbal/Tests/DbalConsumerTest.php b/pkg/dbal/Tests/DbalConsumerTest.php new file mode 100644 index 000000000..0b78eab00 --- /dev/null +++ b/pkg/dbal/Tests/DbalConsumerTest.php @@ -0,0 +1,303 @@ +assertClassImplements(Consumer::class, DbalConsumer::class); + } + + public function testShouldReturnInstanceOfDestination() + { + $destination = new DbalDestination('queue'); + + $consumer = new DbalConsumer($this->createContextMock(), $destination); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testAcknowledgeShouldThrowIfInstanceOfMessageIsInvalid() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage( + 'The message must be an instance of '. + 'Enqueue\Dbal\DbalMessage '. + 'but it is Enqueue\Dbal\Tests\InvalidMessage.' + ); + + $consumer = new DbalConsumer($this->createContextMock(), new DbalDestination('queue')); + $consumer->acknowledge(new InvalidMessage()); + } + + public function testShouldDeleteMessageOnAcknowledge() + { + $deliveryId = Uuid::uuid4(); + + $queue = new DbalDestination('queue'); + + $message = new DbalMessage(); + $message->setBody('theBody'); + $message->setDeliveryId($deliveryId->toString()); + + $dbal = $this->createConectionMock(); + $dbal + ->expects($this->once()) + ->method('delete') + ->with( + 'some-table-name', + ['delivery_id' => $deliveryId->toString()], + ['delivery_id' => DbalType::GUID] + ) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getDbalConnection') + ->willReturn($dbal) + ; + $context + ->expects($this->once()) + ->method('getTableName') + ->willReturn('some-table-name') + ; + + $consumer = new DbalConsumer($context, $queue); + + $consumer->acknowledge($message); + } + + public function testCouldSetAndGetPollingInterval() + { + $destination = new DbalDestination('queue'); + + $consumer = new DbalConsumer($this->createContextMock(), $destination); + $consumer->setPollingInterval(123456); + + $this->assertEquals(123456, $consumer->getPollingInterval()); + } + + public function testCouldSetAndGetRedeliveryDelay() + { + $destination = new DbalDestination('queue'); + + $consumer = new DbalConsumer($this->createContextMock(), $destination); + $consumer->setRedeliveryDelay(123456); + + $this->assertEquals(123456, $consumer->getRedeliveryDelay()); + } + + public function testRejectShouldThrowIfInstanceOfMessageIsInvalid() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage( + 'The message must be an instance of '. + 'Enqueue\Dbal\DbalMessage '. + 'but it is Enqueue\Dbal\Tests\InvalidMessage.' + ); + + $consumer = new DbalConsumer($this->createContextMock(), new DbalDestination('queue')); + $consumer->reject(new InvalidMessage()); + } + + public function testShouldDeleteMessageFromQueueOnReject() + { + $deliveryId = Uuid::uuid4(); + + $queue = new DbalDestination('queue'); + + $message = new DbalMessage(); + $message->setBody('theBody'); + $message->setDeliveryId($deliveryId->toString()); + + $dbal = $this->createConectionMock(); + $dbal + ->expects($this->once()) + ->method('delete') + ->with( + 'some-table-name', + ['delivery_id' => $deliveryId->toString()], + ['delivery_id' => DbalType::GUID] + ) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getDbalConnection') + ->willReturn($dbal) + ; + $context + ->expects($this->once()) + ->method('getTableName') + ->willReturn('some-table-name') + ; + + $consumer = new DbalConsumer($context, $queue); + + $consumer->reject($message); + } + + public function testRejectShouldReSendMessageToSameQueueOnRequeue() + { + $queue = new DbalDestination('queue'); + + $message = new DbalMessage(); + $message->setBody('theBody'); + $message->setDeliveryId(__METHOD__); + + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->isInstanceOf(DbalMessage::class)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producerMock) + ; + + $consumer = new DbalConsumer($context, $queue); + + $consumer->reject($message, true); + } + + /** + * @return DbalProducer|MockObject + */ + private function createProducerMock() + { + return $this->createMock(DbalProducer::class); + } + + /** + * @return MockObject|DbalContext + */ + private function createContextMock() + { + return $this->createMock(DbalContext::class); + } + + /** + * @return MockObject|DbalContext + */ + private function createConectionMock() + { + return $this->createMock(Connection::class); + } +} + +class InvalidMessage implements Message +{ + public function getBody(): string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setBody(string $body): void + { + } + + public function setProperties(array $properties): void + { + } + + public function getProperties(): array + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setProperty(string $name, $value): void + { + } + + public function getProperty(string $name, $default = null) + { + } + + public function setHeaders(array $headers): void + { + } + + public function getHeaders(): array + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setHeader(string $name, $value): void + { + } + + public function getHeader(string $name, $default = null) + { + } + + public function setRedelivered(bool $redelivered): void + { + } + + public function isRedelivered(): bool + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setCorrelationId(?string $correlationId = null): void + { + } + + public function getCorrelationId(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setMessageId(?string $messageId = null): void + { + } + + public function getMessageId(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function getTimestamp(): ?int + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setTimestamp(?int $timestamp = null): void + { + } + + public function setReplyTo(?string $replyTo = null): void + { + } + + public function getReplyTo(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } +} diff --git a/pkg/dbal/Tests/DbalContextTest.php b/pkg/dbal/Tests/DbalContextTest.php new file mode 100644 index 000000000..a1900b788 --- /dev/null +++ b/pkg/dbal/Tests/DbalContextTest.php @@ -0,0 +1,176 @@ +assertClassImplements(Context::class, DbalContext::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $factory = new DbalContext($this->createConnectionMock(), []); + + $this->assertAttributeEquals([ + 'table_name' => 'enqueue', + 'polling_interval' => null, + 'subscription_polling_interval' => null, + ], 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $factory = new DbalContext($this->createConnectionMock(), [ + 'table_name' => 'theTableName', + 'polling_interval' => 12345, + 'subscription_polling_interval' => 12345, + ]); + + $this->assertAttributeEquals([ + 'table_name' => 'theTableName', + 'polling_interval' => 12345, + 'subscription_polling_interval' => 12345, + ], 'config', $factory); + } + + public function testShouldCreateMessage() + { + $context = new DbalContext($this->createConnectionMock()); + $message = $context->createMessage('body', ['pkey' => 'pval'], ['hkey' => 'hval']); + + $this->assertInstanceOf(DbalMessage::class, $message); + $this->assertEquals('body', $message->getBody()); + $this->assertEquals(['pkey' => 'pval'], $message->getProperties()); + $this->assertEquals(['hkey' => 'hval'], $message->getHeaders()); + $this->assertNull($message->getPriority()); + $this->assertFalse($message->isRedelivered()); + } + + public function testShouldConvertArrayToDbalMessage() + { + $arrayData = [ + 'body' => 'theBody', + 'properties' => json_encode(['barProp' => 'barPropVal']), + 'headers' => json_encode(['fooHeader' => 'fooHeaderVal']), + ]; + $context = new DbalContext($this->createConnectionMock()); + $message = $context->convertMessage($arrayData); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } + + public function testShouldCreateTopic() + { + $context = new DbalContext($this->createConnectionMock()); + $topic = $context->createTopic('topic'); + + $this->assertInstanceOf(DbalDestination::class, $topic); + $this->assertEquals('topic', $topic->getTopicName()); + } + + public function testShouldCreateQueue() + { + $context = new DbalContext($this->createConnectionMock()); + $queue = $context->createQueue('queue'); + + $this->assertInstanceOf(DbalDestination::class, $queue); + $this->assertEquals('queue', $queue->getQueueName()); + } + + public function testShouldCreateProducer() + { + $context = new DbalContext($this->createConnectionMock()); + + $this->assertInstanceOf(DbalProducer::class, $context->createProducer()); + } + + public function testShouldCreateConsumer() + { + $context = new DbalContext($this->createConnectionMock()); + + $this->assertInstanceOf(DbalConsumer::class, $context->createConsumer(new DbalDestination(''))); + } + + public function testShouldCreateMessageConsumerAndSetPollingInterval() + { + $context = new DbalContext($this->createConnectionMock(), [ + 'polling_interval' => 123456, + ]); + + $consumer = $context->createConsumer(new DbalDestination('')); + + $this->assertInstanceOf(DbalConsumer::class, $consumer); + $this->assertEquals(123456, $consumer->getPollingInterval()); + } + + public function testShouldThrowIfDestinationIsInvalidInstanceType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage( + 'The destination must be an instance of '. + 'Enqueue\Dbal\DbalDestination but got '. + 'Enqueue\Dbal\Tests\NotSupportedDestination2.' + ); + + $context = new DbalContext($this->createConnectionMock()); + + $this->assertInstanceOf(DbalConsumer::class, $context->createConsumer(new NotSupportedDestination2())); + } + + public function testShouldReturnInstanceOfConnection() + { + $context = new DbalContext($connection = $this->createConnectionMock()); + + $this->assertSame($connection, $context->getDbalConnection()); + } + + public function testShouldReturnConfig() + { + $context = new DbalContext($connection = $this->createConnectionMock()); + + $this->assertSame($connection, $context->getDbalConnection()); + } + + public function testShouldThrowBadMethodCallExceptionOncreateTemporaryQueueCall() + { + $context = new DbalContext($connection = $this->createConnectionMock()); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + /** + * @return MockObject|Connection + */ + private function createConnectionMock() + { + return $this->createMock(Connection::class); + } +} + +class NotSupportedDestination2 implements Destination +{ +} diff --git a/pkg/dbal/Tests/DbalDestinationTest.php b/pkg/dbal/Tests/DbalDestinationTest.php new file mode 100644 index 000000000..65d4fa878 --- /dev/null +++ b/pkg/dbal/Tests/DbalDestinationTest.php @@ -0,0 +1,38 @@ +assertClassImplements(Destination::class, DbalDestination::class); + } + + public function testShouldImplementTopicInterface() + { + $this->assertClassImplements(Topic::class, DbalDestination::class); + } + + public function testShouldImplementQueueInterface() + { + $this->assertClassImplements(Queue::class, DbalDestination::class); + } + + public function testShouldReturnTopicAndQueuePreviouslySetInConstructor() + { + $destination = new DbalDestination('topic-or-queue-name'); + + $this->assertSame('topic-or-queue-name', $destination->getQueueName()); + $this->assertSame('topic-or-queue-name', $destination->getTopicName()); + } +} diff --git a/pkg/dbal/Tests/DbalMessageTest.php b/pkg/dbal/Tests/DbalMessageTest.php new file mode 100644 index 000000000..df38b0d65 --- /dev/null +++ b/pkg/dbal/Tests/DbalMessageTest.php @@ -0,0 +1,94 @@ +assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new DbalMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } + + public function testShouldSetPriorityToNullInConstructor() + { + $message = new DbalMessage(); + + $this->assertNull($message->getPriority()); + } + + public function testShouldSetDelayToNullInConstructor() + { + $message = new DbalMessage(); + + $this->assertNull($message->getDeliveryDelay()); + } + + public function testShouldSetCorrelationIdAsHeader() + { + $message = new DbalMessage(); + $message->setCorrelationId('theCorrelationId'); + + $this->assertSame(['correlation_id' => 'theCorrelationId'], $message->getHeaders()); + } + + public function testShouldSetPublishedAtToNullInConstructor() + { + $message = new DbalMessage(); + + $this->assertNull($message->getPublishedAt()); + } + + public function testShouldSetMessageIdAsHeader() + { + $message = new DbalMessage(); + $message->setMessageId('theMessageId'); + + $this->assertSame(['message_id' => 'theMessageId'], $message->getHeaders()); + } + + public function testShouldSetTimestampAsHeader() + { + $message = new DbalMessage(); + $message->setTimestamp(12345); + + $this->assertSame(['timestamp' => 12345], $message->getHeaders()); + } + + public function testShouldSetReplyToAsHeader() + { + $message = new DbalMessage(); + $message->setReplyTo('theReply'); + + $this->assertSame(['reply_to' => 'theReply'], $message->getHeaders()); + } + + public function testShouldAllowGetPreviouslySetPublishedAtTime() + { + $message = new DbalMessage(); + + $message->setPublishedAt(123); + + $this->assertSame(123, $message->getPublishedAt()); + } +} diff --git a/pkg/dbal/Tests/DbalProducerTest.php b/pkg/dbal/Tests/DbalProducerTest.php new file mode 100644 index 000000000..ec4d2043c --- /dev/null +++ b/pkg/dbal/Tests/DbalProducerTest.php @@ -0,0 +1,49 @@ +assertClassImplements(Producer::class, DbalProducer::class); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage( + 'The destination must be an instance of '. + 'Enqueue\Dbal\DbalDestination but got '. + 'Enqueue\Dbal\Tests\NotSupportedDestination1.' + ); + + $producer = new DbalProducer($this->createContextMock()); + + $producer->send(new NotSupportedDestination1(), new DbalMessage()); + } + + /** + * @return MockObject|DbalContext + */ + private function createContextMock() + { + return $this->createMock(DbalContext::class); + } +} + +class NotSupportedDestination1 implements Destination +{ +} diff --git a/pkg/dbal/Tests/DbalSubscriptionConsumerTest.php b/pkg/dbal/Tests/DbalSubscriptionConsumerTest.php new file mode 100644 index 000000000..bacbec127 --- /dev/null +++ b/pkg/dbal/Tests/DbalSubscriptionConsumerTest.php @@ -0,0 +1,178 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + public function testShouldAddConsumerAndCallbackToSubscribersPropertyOnSubscribe() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + + $this->assertAttributeSame([ + 'foo_queue' => [$fooConsumer, $fooCallback], + 'bar_queue' => [$barConsumer, $barCallback], + ], 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTrySubscribeAnotherConsumerToAlreadySubscribedQueue() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('There is a consumer subscribed to queue: "foo_queue"'); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAllowSubscribeSameConsumerAndCallbackSecondTime() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + } + + public function testShouldRemoveSubscribedConsumerOnUnsubscribeCall() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $fooConsumer = $this->createConsumerStub('foo_queue'); + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, function () {}); + $subscriptionConsumer->subscribe($barConsumer, function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($fooConsumer); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedQueueName() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('bar_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedConsumer() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('foo_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldRemoveAllSubscriberOnUnsubscribeAllCall() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + $subscriptionConsumer->subscribe($this->createConsumerStub('bar_queue'), function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribeAll(); + + $this->assertAttributeCount(0, 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTryConsumeWithoutSubscribers() + { + $subscriptionConsumer = new DbalSubscriptionConsumer($this->createDbalContextMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('No subscribers'); + + $subscriptionConsumer->consume(); + } + + /** + * @return DbalContext|MockObject + */ + private function createDbalContextMock() + { + return $this->createMock(DbalContext::class); + } + + /** + * @param mixed|null $queueName + * + * @return Consumer|MockObject + */ + private function createConsumerStub($queueName = null) + { + $queueMock = $this->createMock(Queue::class); + $queueMock + ->expects($this->any()) + ->method('getQueueName') + ->willReturn($queueName); + + $consumerMock = $this->createMock(DbalConsumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queueMock); + + return $consumerMock; + } +} diff --git a/pkg/dbal/Tests/Functional/DbalConsumerTest.php b/pkg/dbal/Tests/Functional/DbalConsumerTest.php new file mode 100644 index 000000000..8042598b9 --- /dev/null +++ b/pkg/dbal/Tests/Functional/DbalConsumerTest.php @@ -0,0 +1,179 @@ +context = $this->createDbalContext(); + } + + protected function tearDown(): void + { + if ($this->context) { + $this->context->close(); + } + + parent::tearDown(); + } + + public function testShouldSetPublishedAtDateToReceivedMessage() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertSame(0, $this->getQuerySize()); + + $time = (int) (microtime(true) * 10000); + + $expectedBody = __CLASS__.$time; + + $producer = $context->createProducer(); + + /** @var DbalMessage $message */ + $message = $context->createMessage($expectedBody); + $message->setPublishedAt($time); + $producer->send($queue, $message); + + $message = $consumer->receive(100); // 100ms + + $this->assertInstanceOf(DbalMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedBody, $message->getBody()); + $this->assertSame($time, $message->getPublishedAt()); + } + + public function testShouldOrderMessagesWithSamePriorityByPublishedAtDate() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertSame(0, $this->getQuerySize()); + + $time = (int) (microtime(true) * 10000); + $olderTime = $time - 10000; + + $expectedPriority5Body = __CLASS__.'_priority5_'.$time; + $expectedPriority5BodyOlderTime = __CLASS__.'_priority5_'.$olderTime; + + $producer = $context->createProducer(); + + $message = $context->createMessage($expectedPriority5Body); + $message->setPriority(5); + $message->setPublishedAt($time); + $producer->send($queue, $message); + + $message = $context->createMessage($expectedPriority5BodyOlderTime); + $message->setPriority(5); + $message->setPublishedAt($olderTime); + $producer->send($queue, $message); + + $message = $consumer->receive(8000); // 8 sec + + $this->assertInstanceOf(DbalMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedPriority5BodyOlderTime, $message->getBody()); + + $message = $consumer->receive(100); // 8 sec + + $this->assertInstanceOf(DbalMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedPriority5Body, $message->getBody()); + } + + public function testShouldDeleteExpiredMessage() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + // guard + $this->assertSame(0, $this->getQuerySize()); + + $producer = $context->createProducer(); + + $this->context->getDbalConnection()->insert( + $this->context->getTableName(), [ + 'id' => 'id', + 'published_at' => '123', + 'body' => 'expiredMessage', + 'headers' => json_encode([]), + 'properties' => json_encode([]), + 'queue' => __METHOD__, + 'redelivered' => 0, + 'time_to_live' => time() - 10000, + ]); + + $message = $context->createMessage('notExpiredMessage'); + $message->setRedelivered(false); + $producer->send($queue, $message); + + $this->assertSame(2, $this->getQuerySize()); + + // we need a new consumer to workaround redeliver + $consumer = $context->createConsumer($queue); + $message = $consumer->receive(100); + + $this->assertSame(1, $this->getQuerySize()); + + $consumer->acknowledge($message); + + $this->assertSame(0, $this->getQuerySize()); + } + + public function testShouldRemoveOriginalMessageThatHaveBeenRejectedWithRequeue() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertSame(0, $this->getQuerySize()); + + $producer = $context->createProducer(); + + /** @var DbalMessage $message */ + $message = $context->createMessage(__CLASS__); + $producer->send($queue, $message); + + $this->assertSame(1, $this->getQuerySize()); + + $message = $consumer->receive(100); // 100ms + + $this->assertInstanceOf(DbalMessage::class, $message); + $consumer->reject($message, true); + $this->assertSame(1, $this->getQuerySize()); + } + + private function getQuerySize(): int + { + return (int) $this->context->getDbalConnection() + ->executeQuery('SELECT count(*) FROM '.$this->context->getTableName()) + ->fetchOne() + ; + } +} diff --git a/pkg/dbal/Tests/ManagerRegistryConnectionFactoryTest.php b/pkg/dbal/Tests/ManagerRegistryConnectionFactoryTest.php new file mode 100644 index 000000000..83135c2ed --- /dev/null +++ b/pkg/dbal/Tests/ManagerRegistryConnectionFactoryTest.php @@ -0,0 +1,94 @@ +assertClassImplements(ConnectionFactory::class, ManagerRegistryConnectionFactory::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $factory = new ManagerRegistryConnectionFactory($this->createManagerRegistryMock()); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'connection_name' => null, + ], 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $factory = new ManagerRegistryConnectionFactory($this->createManagerRegistryMock(), [ + 'connection_name' => 'theConnectionName', + 'lazy' => false, + ]); + + $this->assertAttributeEquals([ + 'lazy' => false, + 'connection_name' => 'theConnectionName', + ], 'config', $factory); + } + + public function testShouldCreateContext() + { + $registry = $this->createManagerRegistryMock(); + $registry + ->expects($this->once()) + ->method('getConnection') + ->willReturn($this->createConnectionMock()) + ; + + $factory = new ManagerRegistryConnectionFactory($registry, ['lazy' => false]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(DbalContext::class, $context); + + $this->assertAttributeInstanceOf(Connection::class, 'connection', $context); + $this->assertAttributeSame(null, 'connectionFactory', $context); + } + + public function testShouldCreateLazyContext() + { + $factory = new ManagerRegistryConnectionFactory($this->createManagerRegistryMock(), ['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(DbalContext::class, $context); + + $this->assertAttributeEquals(null, 'connection', $context); + $this->assertIsCallable($this->readAttribute($context, 'connectionFactory')); + } + + /** + * @return MockObject|ManagerRegistry + */ + private function createManagerRegistryMock() + { + return $this->createMock(ManagerRegistry::class); + } + + /** + * @return MockObject|Connection + */ + private function createConnectionMock() + { + return $this->createMock(Connection::class); + } +} diff --git a/pkg/dbal/Tests/Spec/DbalConnectionFactoryTest.php b/pkg/dbal/Tests/Spec/DbalConnectionFactoryTest.php new file mode 100644 index 000000000..dc39cffe3 --- /dev/null +++ b/pkg/dbal/Tests/Spec/DbalConnectionFactoryTest.php @@ -0,0 +1,14 @@ +markTestSkipped('The MYSQL_DSN env is not available. Skip tests'); + } + + $factory = new DbalConnectionFactory($env); + + $context = $factory->createContext(); + + if ($context->getDbalConnection()->getSchemaManager()->tablesExist([$context->getTableName()])) { + $context->getDbalConnection()->getSchemaManager()->dropTable($context->getTableName()); + } + + $context->createDataBaseTable(); + + return $context; + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalContextTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalContextTest.php new file mode 100644 index 000000000..f235dd50a --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalContextTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalProducerTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalProducerTest.php new file mode 100644 index 000000000..99cfa2aa6 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalProducerTest.php @@ -0,0 +1,18 @@ +createDbalContext()->createProducer(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalRequeueMessageTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalRequeueMessageTest.php new file mode 100644 index 000000000..a642d7288 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalRequeueMessageTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveDelayedMessageFromQueueTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveDelayedMessageFromQueueTest.php new file mode 100644 index 000000000..2455217d6 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveDelayedMessageFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..6926a3d57 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,49 @@ +publishedAt = (int) (microtime(true) * 10000); + } + + /** + * @return Context + */ + protected function createContext() + { + return $this->createDbalContext(); + } + + /** + * @param DbalContext $context + * + * @return DbalMessage + */ + protected function createMessage(Context $context, $body) + { + /** @var DbalMessage $message */ + $message = parent::createMessage($context, $body); + + // in order to test priorities correctly we have to make sure the messages were sent in the same time. + $message->setPublishedAt($this->publishedAt); + + return $message; + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..7ec1dd64f --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromQueueTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..798e4b844 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromTopicTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..1d6f99456 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveFromTopicTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromQueueTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..d96cb85a4 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromTopicTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..b211fc0ab --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..015f1b716 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..37c406804 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerStopOnFalseTest.php b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..ad59c9e6f --- /dev/null +++ b/pkg/dbal/Tests/Spec/Mysql/DbalSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/CreateDbalContextTrait.php b/pkg/dbal/Tests/Spec/Postgresql/CreateDbalContextTrait.php new file mode 100644 index 000000000..fe5a19a0c --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/CreateDbalContextTrait.php @@ -0,0 +1,27 @@ +markTestSkipped('The POSTGRES_DSN env is not available. Skip tests'); + } + + $factory = new DbalConnectionFactory($env); + + $context = $factory->createContext(); + + if ($context->getDbalConnection()->getSchemaManager()->tablesExist([$context->getTableName()])) { + $context->getDbalConnection()->getSchemaManager()->dropTable($context->getTableName()); + } + + $context->createDataBaseTable(); + + return $context; + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalContextTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalContextTest.php new file mode 100644 index 000000000..b07978cbd --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalContextTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalProducerTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalProducerTest.php new file mode 100644 index 000000000..aa8894de3 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalProducerTest.php @@ -0,0 +1,18 @@ +createDbalContext()->createProducer(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalRequeueMessageTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalRequeueMessageTest.php new file mode 100644 index 000000000..300a572eb --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalRequeueMessageTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveDelayedMessageFromQueueTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveDelayedMessageFromQueueTest.php new file mode 100644 index 000000000..4d915c3b5 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveDelayedMessageFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..556f53b00 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,49 @@ +publishedAt = (int) (microtime(true) * 10000); + } + + /** + * @return Context + */ + protected function createContext() + { + return $this->createDbalContext(); + } + + /** + * @param DbalContext $context + * + * @return DbalMessage + */ + protected function createMessage(Context $context, $body) + { + /** @var DbalMessage $message */ + $message = parent::createMessage($context, $body); + + // in order to test priorities correctly we have to make sure the messages were sent in the same time. + $message->setPublishedAt($this->publishedAt); + + return $message; + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..db92febe3 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromQueueTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..63e4456f0 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromTopicTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..a2989fd54 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveFromTopicTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromQueueTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..9a08f3676 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromTopicTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..4383acd36 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,18 @@ +createDbalContext(); + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..d2c8ee22e --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..892adf372 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerStopOnFalseTest.php b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..9eeb918b8 --- /dev/null +++ b/pkg/dbal/Tests/Spec/Postgresql/DbalSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,37 @@ +createDbalContext(); + } + + /** + * @param DbalContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/dbal/composer.json b/pkg/dbal/composer.json new file mode 100644 index 000000000..9499d394d --- /dev/null +++ b/pkg/dbal/composer.json @@ -0,0 +1,40 @@ +{ + "name": "enqueue/dbal", + "type": "library", + "description": "Message Queue Doctrine DBAL Transport", + "keywords": ["messaging", "queue", "doctrine", "dbal"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/queue-interop": "^0.8", + "doctrine/dbal": "^2.12|^3.1", + "doctrine/persistence": "^2.0|^3.0", + "ramsey/uuid": "^3.5|^4" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Dbal\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/dbal/examples/consume.php b/pkg/dbal/examples/consume.php new file mode 100644 index 000000000..f63cf8a77 --- /dev/null +++ b/pkg/dbal/examples/consume.php @@ -0,0 +1,42 @@ + [ + 'url' => getenv('DOCTRINE_DSN'), + 'driver' => 'pdo_mysql', + ], +]; + +$factory = new DbalConnectionFactory($config); +$context = $factory->createContext(); +$context->createDataBaseTable(); + +$destination = $context->createTopic('destination'); + +$consumer = $context->createConsumer($destination); + +while (true) { + if ($m = $consumer->receive(1000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().\PHP_EOL; + } +} + +echo 'Done'."\n"; diff --git a/pkg/dbal/examples/produce.php b/pkg/dbal/examples/produce.php new file mode 100644 index 000000000..9f282bd0b --- /dev/null +++ b/pkg/dbal/examples/produce.php @@ -0,0 +1,41 @@ + [ + 'url' => getenv('DOCTRINE_DSN'), + 'driver' => 'pdo_mysql', + ], +]; + +$factory = new DbalConnectionFactory($config); +$context = $factory->createContext(); +$context->createDataBaseTable(); + +$destination = $context->createTopic('destination'); + +$message = $context->createMessage('Hello Bar!'); + +while (true) { + $context->createProducer()->send($destination, $message); + echo 'Sent message: '.$message->getBody().\PHP_EOL; + sleep(1); +} + +echo 'Done'."\n"; diff --git a/pkg/dbal/phpunit.xml.dist b/pkg/dbal/phpunit.xml.dist new file mode 100644 index 000000000..55a8d1f29 --- /dev/null +++ b/pkg/dbal/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/dsn/.gitattributes b/pkg/dsn/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/dsn/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/dsn/.github/workflows/ci.yml b/pkg/dsn/.github/workflows/ci.yml new file mode 100644 index 000000000..71bcbbd61 --- /dev/null +++ b/pkg/dsn/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit diff --git a/pkg/dsn/.gitignore b/pkg/dsn/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/dsn/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/dsn/Dsn.php b/pkg/dsn/Dsn.php new file mode 100644 index 000000000..f46d7c056 --- /dev/null +++ b/pkg/dsn/Dsn.php @@ -0,0 +1,354 @@ +scheme = $scheme; + $this->schemeProtocol = $schemeProtocol; + $this->schemeExtensions = $schemeExtensions; + $this->user = $user; + $this->password = $password; + $this->host = $host; + $this->port = $port; + $this->path = $path; + $this->queryString = $queryString; + $this->queryBag = new QueryBag($query); + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getSchemeProtocol(): string + { + return $this->schemeProtocol; + } + + public function getSchemeExtensions(): array + { + return $this->schemeExtensions; + } + + public function hasSchemeExtension(string $extension): bool + { + return in_array($extension, $this->schemeExtensions, true); + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getHost(): ?string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function getQueryString(): ?string + { + return $this->queryString; + } + + public function getQueryBag(): QueryBag + { + return $this->queryBag; + } + + public function getQuery(): array + { + return $this->queryBag->toArray(); + } + + public function getString(string $name, ?string $default = null): ?string + { + return $this->queryBag->getString($name, $default); + } + + public function getDecimal(string $name, ?int $default = null): ?int + { + return $this->queryBag->getDecimal($name, $default); + } + + public function getOctal(string $name, ?int $default = null): ?int + { + return $this->queryBag->getOctal($name, $default); + } + + public function getFloat(string $name, ?float $default = null): ?float + { + return $this->queryBag->getFloat($name, $default); + } + + public function getBool(string $name, ?bool $default = null): ?bool + { + return $this->queryBag->getBool($name, $default); + } + + public function getArray(string $name, array $default = []): QueryBag + { + return $this->queryBag->getArray($name, $default); + } + + public function toArray() + { + return [ + 'scheme' => $this->scheme, + 'schemeProtocol' => $this->schemeProtocol, + 'schemeExtensions' => $this->schemeExtensions, + 'user' => $this->user, + 'password' => $this->password, + 'host' => $this->host, + 'port' => $this->port, + 'path' => $this->path, + 'queryString' => $this->queryString, + 'query' => $this->queryBag->toArray(), + ]; + } + + public static function parseFirst(string $dsn): ?self + { + return self::parse($dsn)[0]; + } + + /** + * @return Dsn[] + */ + public static function parse(string $dsn): array + { + if (!str_contains($dsn, ':')) { + throw new \LogicException('The DSN is invalid. It does not have scheme separator ":".'); + } + + list($scheme, $dsnWithoutScheme) = explode(':', $dsn, 2); + + $scheme = strtolower($scheme); + if (false == preg_match('/^[a-z\d+-.]*$/', $scheme)) { + throw new \LogicException('The DSN is invalid. Scheme contains illegal symbols.'); + } + + $schemeParts = explode('+', $scheme); + $schemeProtocol = $schemeParts[0]; + + unset($schemeParts[0]); + $schemeExtensions = array_values($schemeParts); + + $user = parse_url($dsn, \PHP_URL_USER) ?: null; + if (is_string($user)) { + $user = rawurldecode($user); + } + + $password = parse_url($dsn, \PHP_URL_PASS) ?: null; + if (is_string($password)) { + $password = rawurldecode($password); + } + + $path = parse_url($dsn, \PHP_URL_PATH) ?: null; + if ($path) { + $path = rawurldecode($path); + } + + $query = []; + $queryString = parse_url($dsn, \PHP_URL_QUERY) ?: null; + if (is_string($queryString)) { + $query = self::httpParseQuery($queryString, '&', \PHP_QUERY_RFC3986); + } + $hostsPorts = ''; + if (str_starts_with($dsnWithoutScheme, '//')) { + $dsnWithoutScheme = substr($dsnWithoutScheme, 2); + $dsnWithoutUserPassword = explode('@', $dsnWithoutScheme, 2); + $dsnWithoutUserPassword = 2 === count($dsnWithoutUserPassword) ? + $dsnWithoutUserPassword[1] : + $dsnWithoutUserPassword[0] + ; + + list($hostsPorts) = explode('#', $dsnWithoutUserPassword, 2); + list($hostsPorts) = explode('?', $hostsPorts, 2); + list($hostsPorts) = explode('/', $hostsPorts, 2); + } + + if (empty($hostsPorts)) { + return [ + new self( + $scheme, + $schemeProtocol, + $schemeExtensions, + null, + null, + null, + null, + $path, + $queryString, + $query + ), + ]; + } + + $dsns = []; + $hostParts = explode(',', $hostsPorts); + foreach ($hostParts as $key => $hostPart) { + unset($hostParts[$key]); + + $parts = explode(':', $hostPart, 2); + $host = $parts[0]; + + $port = null; + if (isset($parts[1])) { + $port = (int) $parts[1]; + } + + $dsns[] = new self( + $scheme, + $schemeProtocol, + $schemeExtensions, + $user, + $password, + $host, + $port, + $path, + $queryString, + $query + ); + } + + return $dsns; + } + + /** + * based on http://php.net/manual/en/function.parse-str.php#119484 with some slight modifications. + */ + private static function httpParseQuery(string $queryString, string $argSeparator = '&', int $decType = \PHP_QUERY_RFC1738): array + { + $result = []; + $parts = explode($argSeparator, $queryString); + + foreach ($parts as $part) { + list($paramName, $paramValue) = explode('=', $part, 2); + + switch ($decType) { + case \PHP_QUERY_RFC3986: + $paramName = rawurldecode($paramName); + $paramValue = rawurldecode($paramValue); + break; + case \PHP_QUERY_RFC1738: + default: + $paramName = urldecode($paramName); + $paramValue = urldecode($paramValue); + break; + } + + if (preg_match_all('/\[([^\]]*)\]/m', $paramName, $matches)) { + $paramName = substr($paramName, 0, strpos($paramName, '[')); + $keys = array_merge([$paramName], $matches[1]); + } else { + $keys = [$paramName]; + } + + $target = &$result; + + foreach ($keys as $index) { + if ('' === $index) { + if (is_array($target)) { + $intKeys = array_filter(array_keys($target), 'is_int'); + $index = count($intKeys) ? max($intKeys) + 1 : 0; + } else { + $target = [$target]; + $index = 1; + } + } elseif (isset($target[$index]) && !is_array($target[$index])) { + $target[$index] = [$target[$index]]; + } + + $target = &$target[$index]; + } + + if (is_array($target)) { + $target[] = $paramValue; + } else { + $target = $paramValue; + } + } + + return $result; + } +} diff --git a/pkg/dsn/InvalidQueryParameterTypeException.php b/pkg/dsn/InvalidQueryParameterTypeException.php new file mode 100644 index 000000000..e419fa6a2 --- /dev/null +++ b/pkg/dsn/InvalidQueryParameterTypeException.php @@ -0,0 +1,11 @@ +query = $query; + } + + public function toArray(): array + { + return $this->query; + } + + public function getString(string $name, ?string $default = null): ?string + { + return array_key_exists($name, $this->query) ? $this->query[$name] : $default; + } + + public function getDecimal(string $name, ?int $default = null): ?int + { + $value = $this->getString($name); + if (null === $value) { + return $default; + } + + if (false == preg_match('/^[\+\-]?[0-9]*$/', $value)) { + throw InvalidQueryParameterTypeException::create($name, 'decimal'); + } + + return (int) $value; + } + + public function getOctal(string $name, ?int $default = null): ?int + { + $value = $this->getString($name); + if (null === $value) { + return $default; + } + + if (false == preg_match('/^0[\+\-]?[0-7]*$/', $value)) { + throw InvalidQueryParameterTypeException::create($name, 'octal'); + } + + return intval($value, 8); + } + + public function getFloat(string $name, ?float $default = null): ?float + { + $value = $this->getString($name); + if (null === $value) { + return $default; + } + + if (false == is_numeric($value)) { + throw InvalidQueryParameterTypeException::create($name, 'float'); + } + + return (float) $value; + } + + public function getBool(string $name, ?bool $default = null): ?bool + { + $value = $this->getString($name); + if (null === $value) { + return $default; + } + + if (in_array($value, ['', '0', 'false'], true)) { + return false; + } + + if (in_array($value, ['1', 'true'], true)) { + return true; + } + + throw InvalidQueryParameterTypeException::create($name, 'bool'); + } + + public function getArray(string $name, array $default = []): self + { + if (false == array_key_exists($name, $this->query)) { + return new self($default); + } + + $value = $this->query[$name]; + + if (is_array($value)) { + return new self($value); + } + + throw InvalidQueryParameterTypeException::create($name, 'array'); + } +} diff --git a/pkg/dsn/README.md b/pkg/dsn/README.md new file mode 100644 index 000000000..ca0ce7da9 --- /dev/null +++ b/pkg/dsn/README.md @@ -0,0 +1,29 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Enqueue. Parse DSN class + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/dsn.md) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/dsn/Tests/DsnTest.php b/pkg/dsn/Tests/DsnTest.php new file mode 100644 index 000000000..8bf4137ed --- /dev/null +++ b/pkg/dsn/Tests/DsnTest.php @@ -0,0 +1,479 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid. It does not have scheme separator ":".'); + Dsn::parseFirst('foobar'); + } + + public function testThrowsIfSchemeContainsIllegalSymbols() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid. Scheme contains illegal symbols.'); + Dsn::parseFirst('foo_&%&^bar://localhost'); + } + + /** + * @dataProvider provideSchemes + */ + public function testShouldParseSchemeCorrectly(string $dsn, string $expectedScheme, string $expectedSchemeProtocol, array $expectedSchemeExtensions) + { + $dsn = Dsn::parseFirst($dsn); + + $this->assertSame($expectedScheme, $dsn->getScheme()); + $this->assertSame($expectedSchemeProtocol, $dsn->getSchemeProtocol()); + $this->assertSame($expectedSchemeExtensions, $dsn->getSchemeExtensions()); + } + + public function testShouldParseUser() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath'); + + $this->assertSame('theUser', $dsn->getUser()); + } + + public function testShouldParsePassword() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath'); + + $this->assertSame('thePass', $dsn->getPassword()); + } + + public function testShouldParseHost() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath'); + + $this->assertSame('theHost', $dsn->getHost()); + } + + public function testShouldParsePort() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath'); + + $this->assertSame(1267, $dsn->getPort()); + } + + public function testShouldParsePath() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath'); + + $this->assertSame('/thePath', $dsn->getPath()); + } + + public function testShouldUrlDecodedPath() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/%2f'); + + $this->assertSame('//', $dsn->getPath()); + } + + public function testShouldParseQuery() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath?foo=fooVal&bar=bar%2fVal'); + + $this->assertSame('foo=fooVal&bar=bar%2fVal', $dsn->getQueryString()); + $this->assertSame(['foo' => 'fooVal', 'bar' => 'bar/Val'], $dsn->getQuery()); + } + + public function testShouldParseQueryShouldPreservePlusSymbol() + { + $dsn = Dsn::parseFirst('amqp+ext://theUser:thePass@theHost:1267/thePath?foo=fooVal&bar=bar+Val'); + + $this->assertSame('foo=fooVal&bar=bar+Val', $dsn->getQueryString()); + $this->assertSame(['foo' => 'fooVal', 'bar' => 'bar+Val'], $dsn->getQuery()); + } + + /** + * @dataProvider provideIntQueryParameters + */ + public function testShouldParseQueryParameterAsInt(string $parameter, int $expected) + { + $dsn = Dsn::parseFirst('foo:?aName='.$parameter); + + $this->assertSame($expected, $dsn->getDecimal('aName')); + } + + /** + * @dataProvider provideOctalQueryParameters + */ + public function testShouldParseQueryParameterAsOctalInt(string $parameter, int $expected) + { + $dsn = Dsn::parseFirst('foo:?aName='.$parameter); + + $this->assertSame($expected, $dsn->getOctal('aName')); + } + + public function testShouldReturnDefaultIntIfNotSet() + { + $dsn = Dsn::parseFirst('foo:'); + + $this->assertNull($dsn->getDecimal('aName')); + $this->assertSame(123, $dsn->getDecimal('aName', 123)); + } + + public function testThrowIfQueryParameterNotDecimal() + { + $dsn = Dsn::parseFirst('foo:?aName=notInt'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "decimal"'); + $dsn->getDecimal('aName'); + } + + public function testThrowIfQueryParameterNotOctalButString() + { + $dsn = Dsn::parseFirst('foo:?aName=notInt'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "octal"'); + $dsn->getOctal('aName'); + } + + public function testThrowIfQueryParameterNotOctalButDecimal() + { + $dsn = Dsn::parseFirst('foo:?aName=123'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "octal"'); + $dsn->getOctal('aName'); + } + + public function testThrowIfQueryParameterInvalidOctal() + { + $dsn = Dsn::parseFirst('foo:?aName=0128'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "octal"'); + $dsn->getOctal('aName'); + } + + public function testThrowIfQueryParameterInvalidArray() + { + $dsn = Dsn::parseFirst('foo:?aName=foo'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "array"'); + $dsn->getArray('aName'); + } + + /** + * @dataProvider provideFloatQueryParameters + */ + public function testShouldParseQueryParameterAsFloat(string $parameter, float $expected) + { + $dsn = Dsn::parseFirst('foo:?aName='.$parameter); + + $this->assertSame($expected, $dsn->getFloat('aName')); + } + + public function testShouldParseDSNWithoutAuthorityPart() + { + $dsn = Dsn::parseFirst('foo:///foo'); + + $this->assertNull($dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertNull($dsn->getHost()); + $this->assertNull($dsn->getPort()); + } + + public function testShouldReturnDefaultFloatIfNotSet() + { + $dsn = Dsn::parseFirst('foo:'); + + $this->assertNull($dsn->getFloat('aName')); + $this->assertSame(123., $dsn->getFloat('aName', 123.)); + } + + public function testThrowIfQueryParameterNotFloat() + { + $dsn = Dsn::parseFirst('foo:?aName=notFloat'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "float"'); + $dsn->getFloat('aName'); + } + + /** + * @dataProvider provideBooleanQueryParameters + */ + public function testShouldParseQueryParameterAsBoolean(string $parameter, bool $expected) + { + $dsn = Dsn::parseFirst('foo:?aName='.$parameter); + + $this->assertSame($expected, $dsn->getBool('aName')); + } + + /** + * @dataProvider provideArrayQueryParameters + */ + public function testShouldParseQueryParameterAsArray(string $query, array $expected) + { + $dsn = Dsn::parseFirst('foo:?'.$query); + + $this->assertSame($expected, $dsn->getArray('aName')->toArray()); + } + + public function testShouldReturnDefaultBoolIfNotSet() + { + $dsn = Dsn::parseFirst('foo:'); + + $this->assertNull($dsn->getBool('aName')); + $this->assertTrue($dsn->getBool('aName', true)); + } + + public function testThrowIfQueryParameterNotBool() + { + $dsn = Dsn::parseFirst('foo:?aName=notBool'); + + $this->expectException(InvalidQueryParameterTypeException::class); + $this->expectExceptionMessage('The query parameter "aName" has invalid type. It must be "bool"'); + $dsn->getBool('aName'); + } + + public function testShouldParseMultipleDsnsWithUsernameAndPassword() + { + $dsns = Dsn::parse('foo://user:pass@foo,bar'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('user', $dsns[0]->getUser()); + $this->assertSame('pass', $dsns[0]->getPassword()); + $this->assertSame('foo', $dsns[0]->getHost()); + + $this->assertSame('user', $dsns[1]->getUser()); + $this->assertSame('pass', $dsns[1]->getPassword()); + $this->assertSame('bar', $dsns[1]->getHost()); + } + + public function testShouldParseMultipleDsnsWithPorts() + { + $dsns = Dsn::parse('foo://foo:123,bar:567'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertSame(123, $dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertSame(567, $dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsWhenFirstHasPort() + { + $dsns = Dsn::parse('foo://foo:123,bar'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertSame(123, $dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertNull($dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsWhenLastHasPort() + { + $dsns = Dsn::parse('foo://foo,bar:567'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertNull($dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertSame(567, $dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsWithPath() + { + $dsns = Dsn::parse('foo://foo:123,bar:567/foo/bar'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertSame(123, $dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertSame(567, $dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsWithQuery() + { + $dsns = Dsn::parse('foo://foo:123,bar:567?foo=val'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertSame(123, $dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertSame(567, $dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsWithQueryAndPath() + { + $dsns = Dsn::parse('foo://foo:123,bar:567/foo?foo=val'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $this->assertSame('foo', $dsns[0]->getHost()); + $this->assertSame(123, $dsns[0]->getPort()); + + $this->assertSame('bar', $dsns[1]->getHost()); + $this->assertSame(567, $dsns[1]->getPort()); + } + + public function testShouldParseMultipleDsnsIfOnlyColonProvided() + { + $dsns = Dsn::parse(':'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(1, $dsns); + + $this->assertNull($dsns[0]->getHost()); + $this->assertNull($dsns[0]->getPort()); + } + + public function testShouldParseMultipleDsnsWithOnlyScheme() + { + $dsns = Dsn::parse('foo:'); + + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(1, $dsns); + + $this->assertSame('foo', $dsns[0]->getScheme()); + $this->assertNull($dsns[0]->getHost()); + $this->assertNull($dsns[0]->getPort()); + } + + public function testShouldParseExpectedNumberOfMultipleDsns() + { + $dsns = Dsn::parse('foo://foo'); + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(1, $dsns); + + $dsns = Dsn::parse('foo://foo,bar'); + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(2, $dsns); + + $dsns = Dsn::parse('foo://foo,bar,baz'); + $this->assertContainsOnly(Dsn::class, $dsns); + $this->assertCount(3, $dsns); + } + + public function testShouldParseDsnWithOnlyUser() + { + $dsn = Dsn::parseFirst('foo://user@host'); + + $this->assertSame('user', $dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertSame('foo', $dsn->getScheme()); + $this->assertSame('host', $dsn->getHost()); + } + + public function testShouldUrlEncodeUser() + { + $dsn = Dsn::parseFirst('foo://us%3Aer@host'); + + $this->assertSame('us:er', $dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertSame('foo', $dsn->getScheme()); + $this->assertSame('host', $dsn->getHost()); + } + + public function testShouldUrlEncodePassword() + { + $dsn = Dsn::parseFirst('foo://user:pass%3Aword@host'); + + $this->assertSame('user', $dsn->getUser()); + $this->assertSame('pass:word', $dsn->getPassword()); + $this->assertSame('foo', $dsn->getScheme()); + $this->assertSame('host', $dsn->getHost()); + } + + public static function provideSchemes() + { + yield [':', '', '', []]; + + yield ['FOO:', 'foo', 'foo', []]; + + yield ['foo:', 'foo', 'foo', []]; + + yield ['foo+bar:', 'foo+bar', 'foo', ['bar']]; + + yield ['foo+bar+baz:', 'foo+bar+baz', 'foo', ['bar', 'baz']]; + + yield ['foo:?bar=barVal', 'foo', 'foo', []]; + + yield ['amqp+ext://guest:guest@localhost:5672/%2f', 'amqp+ext', 'amqp', ['ext']]; + + yield ['amqp+ext+rabbitmq:', 'amqp+ext+rabbitmq', 'amqp', ['ext', 'rabbitmq']]; + } + + public static function provideIntQueryParameters() + { + yield ['123', 123]; + + yield ['+123', 123]; + + yield ['-123', -123]; + + yield ['010', 10]; + } + + public static function provideOctalQueryParameters() + { + yield ['010', 8]; + } + + public static function provideFloatQueryParameters() + { + yield ['123', 123.]; + + yield ['+123', 123.]; + + yield ['-123', -123.]; + + yield ['0', 0.]; + } + + public static function provideBooleanQueryParameters() + { + yield ['', false]; + + yield ['1', true]; + + yield ['0', false]; + + yield ['true', true]; + + yield ['false', false]; + } + + public static function provideArrayQueryParameters() + { + yield ['aName[0]=val', ['val']]; + + yield ['aName[key]=val', ['key' => 'val']]; + + yield ['aName[0]=fooVal&aName[1]=barVal', ['fooVal', 'barVal']]; + + yield ['aName[foo]=fooVal&aName[bar]=barVal', ['foo' => 'fooVal', 'bar' => 'barVal']]; + } +} diff --git a/pkg/dsn/composer.json b/pkg/dsn/composer.json new file mode 100644 index 000000000..dbd39aed7 --- /dev/null +++ b/pkg/dsn/composer.json @@ -0,0 +1,33 @@ +{ + "name": "enqueue/dsn", + "type": "library", + "description": "Parse DSN", + "keywords": ["dsn", "parse"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Dsn\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/dsn/phpunit.xml.dist b/pkg/dsn/phpunit.xml.dist new file mode 100644 index 000000000..43b743e2a --- /dev/null +++ b/pkg/dsn/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/enqueue-bundle/.gitattributes b/pkg/enqueue-bundle/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/enqueue-bundle/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/enqueue-bundle/.github/workflows/ci.yml b/pkg/enqueue-bundle/.github/workflows/ci.yml new file mode 100644 index 000000000..4c397bef1 --- /dev/null +++ b/pkg/enqueue-bundle/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mongodb + + - run: php Tests/fix_composer_json.php + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/enqueue-bundle/.travis.yml b/pkg/enqueue-bundle/.travis.yml deleted file mode 100644 index aaa1849c3..000000000 --- a/pkg/enqueue-bundle/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer self-update - - composer install --prefer-source --ignore-platform-reqs - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php b/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php index 9750fb399..d02b9a274 100644 --- a/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php +++ b/pkg/enqueue-bundle/Consumption/Extension/DoctrineClearIdentityMapExtension.php @@ -2,32 +2,23 @@ namespace Enqueue\Bundle\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; -use Symfony\Bridge\Doctrine\RegistryInterface; +use Doctrine\Persistence\ManagerRegistry; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\MessageReceivedExtensionInterface; -class DoctrineClearIdentityMapExtension implements ExtensionInterface +class DoctrineClearIdentityMapExtension implements MessageReceivedExtensionInterface { - use EmptyExtensionTrait; - /** - * @var RegistryInterface + * @var ManagerRegistry */ protected $registry; - /** - * @param RegistryInterface $registry - */ - public function __construct(RegistryInterface $registry) + public function __construct(ManagerRegistry $registry) { $this->registry = $registry; } - /** - * {@inheritdoc} - */ - public function onPreReceived(Context $context) + public function onMessageReceived(MessageReceived $context): void { foreach ($this->registry->getManagers() as $name => $manager) { $context->getLogger()->debug(sprintf( diff --git a/pkg/enqueue-bundle/Consumption/Extension/DoctrineClosedEntityManagerExtension.php b/pkg/enqueue-bundle/Consumption/Extension/DoctrineClosedEntityManagerExtension.php new file mode 100644 index 000000000..e5ad0c6cf --- /dev/null +++ b/pkg/enqueue-bundle/Consumption/Extension/DoctrineClosedEntityManagerExtension.php @@ -0,0 +1,65 @@ +registry = $registry; + } + + public function onPreConsume(PreConsume $context): void + { + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } + } + + public function onPostConsume(PostConsume $context): void + { + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } + } + + public function onPostMessageReceived(PostMessageReceived $context): void + { + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } + } + + private function shouldBeStopped(LoggerInterface $logger): bool + { + foreach ($this->registry->getManagers() as $name => $manager) { + if (!$manager instanceof EntityManagerInterface || $manager->isOpen()) { + continue; + } + + $logger->debug(sprintf( + '[DoctrineClosedEntityManagerExtension] Interrupt execution as entity manager "%s" has been closed', + $name + )); + + return true; + } + + return false; + } +} diff --git a/pkg/enqueue-bundle/Consumption/Extension/DoctrinePingConnectionExtension.php b/pkg/enqueue-bundle/Consumption/Extension/DoctrinePingConnectionExtension.php index 6f3770ee8..7fd9527db 100644 --- a/pkg/enqueue-bundle/Consumption/Extension/DoctrinePingConnectionExtension.php +++ b/pkg/enqueue-bundle/Consumption/Extension/DoctrinePingConnectionExtension.php @@ -3,37 +3,32 @@ namespace Enqueue\Bundle\Consumption\Extension; use Doctrine\DBAL\Connection; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; -use Symfony\Bridge\Doctrine\RegistryInterface; +use Doctrine\Persistence\ManagerRegistry; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\MessageReceivedExtensionInterface; -class DoctrinePingConnectionExtension implements ExtensionInterface +class DoctrinePingConnectionExtension implements MessageReceivedExtensionInterface { - use EmptyExtensionTrait; - /** - * @var RegistryInterface + * @var ManagerRegistry */ protected $registry; - /** - * @param RegistryInterface $registry - */ - public function __construct(RegistryInterface $registry) + public function __construct(ManagerRegistry $registry) { $this->registry = $registry; } - /** - * {@inheritdoc} - */ - public function onPreReceived(Context $context) + public function onMessageReceived(MessageReceived $context): void { /** @var Connection $connection */ foreach ($this->registry->getConnections() as $connection) { - if ($connection->ping()) { - return; + if (!$connection->isConnected()) { + continue; + } + + if ($this->ping($connection)) { + continue; } $context->getLogger()->debug( @@ -48,4 +43,23 @@ public function onPreReceived(Context $context) ); } } + + private function ping(Connection $connection): bool + { + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + throw new \ErrorException($message, $severity, $severity, $file, $line); + }); + + try { + $dummySelectSQL = $connection->getDatabasePlatform()->getDummySelectSQL(); + + $connection->executeQuery($dummySelectSQL); + + return true; + } catch (\Throwable $exception) { + return false; + } finally { + restore_error_handler(); + } + } } diff --git a/pkg/enqueue-bundle/Consumption/Extension/ResetServicesExtension.php b/pkg/enqueue-bundle/Consumption/Extension/ResetServicesExtension.php new file mode 100644 index 000000000..0bf642197 --- /dev/null +++ b/pkg/enqueue-bundle/Consumption/Extension/ResetServicesExtension.php @@ -0,0 +1,27 @@ +resetter = $resetter; + } + + public function onPostMessageReceived(PostMessageReceived $context): void + { + $context->getLogger()->debug('[ResetServicesExtension] Resetting services.'); + + $this->resetter->reset(); + } +} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/AddTopicMetaPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/AddTopicMetaPass.php deleted file mode 100644 index ba4269b2d..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/AddTopicMetaPass.php +++ /dev/null @@ -1,65 +0,0 @@ -topicsMeta = []; - } - - /** - * @param string $topicName - * @param string $topicDescription - * @param array $topicSubscribers - * - * @return $this - */ - public function add($topicName, $topicDescription = '', array $topicSubscribers = []) - { - $this->topicsMeta[$topicName] = []; - - if ($topicDescription) { - $this->topicsMeta[$topicName]['description'] = $topicDescription; - } - - if ($topicSubscribers) { - $this->topicsMeta[$topicName]['processors'] = $topicSubscribers; - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function process(ContainerBuilder $container) - { - $metaRegistryId = 'enqueue.client.meta.topic_meta_registry'; - - if (false == $container->hasDefinition($metaRegistryId)) { - return; - } - - $metaRegistry = $container->getDefinition($metaRegistryId); - - $metaRegistry->replaceArgument(0, array_merge_recursive($metaRegistry->getArgument(0), $this->topicsMeta)); - } - - /** - * @return static - */ - public static function create() - { - return new static(); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildClientRoutingPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildClientRoutingPass.php deleted file mode 100644 index cb924de42..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildClientRoutingPass.php +++ /dev/null @@ -1,39 +0,0 @@ -hasDefinition($routerId)) { - return; - } - - $configs = []; - foreach ($container->findTaggedServiceIds($processorTagName) as $serviceId => $tagAttributes) { - $subscriptions = $this->extractSubscriptions($container, $serviceId, $tagAttributes); - - foreach ($subscriptions as $subscription) { - $configs[$subscription['topicName']][] = [ - $subscription['processorName'], - $subscription['queueName'], - ]; - } - } - - $router = $container->getDefinition($routerId); - $router->replaceArgument(1, $configs); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildExtensionsPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildExtensionsPass.php deleted file mode 100644 index c86b5a35d..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildExtensionsPass.php +++ /dev/null @@ -1,36 +0,0 @@ -findTaggedServiceIds('enqueue.consumption.extension'); - - $groupByPriority = []; - foreach ($tags as $serviceId => $tagAttributes) { - foreach ($tagAttributes as $tagAttribute) { - $priority = isset($tagAttribute['priority']) ? (int) $tagAttribute['priority'] : 0; - - $groupByPriority[$priority][] = new Reference($serviceId); - } - } - - ksort($groupByPriority); - - $flatExtensions = []; - foreach ($groupByPriority as $extension) { - $flatExtensions = array_merge($flatExtensions, $extension); - } - - $container->getDefinition('enqueue.consumption.extensions')->replaceArgument(0, $flatExtensions); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildProcessorRegistryPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildProcessorRegistryPass.php deleted file mode 100644 index ffff5b4ed..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildProcessorRegistryPass.php +++ /dev/null @@ -1,36 +0,0 @@ -hasDefinition($processorRegistryId)) { - return; - } - - $processorIds = []; - foreach ($container->findTaggedServiceIds($processorTagName) as $serviceId => $tagAttributes) { - $subscriptions = $this->extractSubscriptions($container, $serviceId, $tagAttributes); - - foreach ($subscriptions as $subscription) { - $processorIds[$subscription['processorName']] = $serviceId; - } - } - - $processorRegistryDef = $container->getDefinition($processorRegistryId); - $processorRegistryDef->setArguments([$processorIds]); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildQueueMetaRegistryPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildQueueMetaRegistryPass.php deleted file mode 100644 index af9ffea2f..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildQueueMetaRegistryPass.php +++ /dev/null @@ -1,36 +0,0 @@ -hasDefinition($queueMetaRegistryId)) { - return; - } - - $queueMetaRegistry = $container->getDefinition($queueMetaRegistryId); - - $configs = []; - foreach ($container->findTaggedServiceIds($processorTagName) as $serviceId => $tagAttributes) { - $subscriptions = $this->extractSubscriptions($container, $serviceId, $tagAttributes); - - foreach ($subscriptions as $subscription) { - $configs[$subscription['queueName']]['processors'][] = $subscription['processorName']; - } - } - - $queueMetaRegistry->replaceArgument(1, $configs); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildTopicMetaSubscribersPass.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildTopicMetaSubscribersPass.php deleted file mode 100644 index b2a339eaa..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/BuildTopicMetaSubscribersPass.php +++ /dev/null @@ -1,35 +0,0 @@ -findTaggedServiceIds($processorTagName) as $serviceId => $tagAttributes) { - $subscriptions = $this->extractSubscriptions($container, $serviceId, $tagAttributes); - - foreach ($subscriptions as $subscription) { - $topicsSubscribers[$subscription['topicName']][] = $subscription['processorName']; - } - } - - $addTopicMetaPass = AddTopicMetaPass::create(); - foreach ($topicsSubscribers as $topicName => $subscribers) { - $addTopicMetaPass->add($topicName, '', $subscribers); - } - - $addTopicMetaPass->process($container); - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Compiler/ExtractProcessorTagSubscriptionsTrait.php b/pkg/enqueue-bundle/DependencyInjection/Compiler/ExtractProcessorTagSubscriptionsTrait.php deleted file mode 100644 index cd15177d9..000000000 --- a/pkg/enqueue-bundle/DependencyInjection/Compiler/ExtractProcessorTagSubscriptionsTrait.php +++ /dev/null @@ -1,86 +0,0 @@ -getParameter(trim($value, '%')); - } catch (ParameterNotFoundException $e) { - return $value; - } - }; - - $processorClass = $container->getDefinition($processorServiceId)->getClass(); - if (false == class_exists($processorClass)) { - throw new \LogicException(sprintf('The class "%s" could not be found.', $processorClass)); - } - - $defaultQueueName = $resolve($container->getParameter('enqueue.client.default_queue_name')); - $subscriptionPrototype = [ - 'topicName' => null, - 'queueName' => null, - 'processorName' => null, - ]; - - $data = []; - if (is_subclass_of($processorClass, TopicSubscriberInterface::class)) { - foreach ($processorClass::getSubscribedTopics() as $topicName => $params) { - if (is_string($params)) { - $data[] = [ - 'topicName' => $params, - 'queueName' => $defaultQueueName, - 'processorName' => $processorServiceId, - ]; - } elseif (is_array($params)) { - $params = array_replace($subscriptionPrototype, $params); - - $data[] = [ - 'topicName' => $topicName, - 'queueName' => $resolve($params['queueName']) ?: $defaultQueueName, - 'processorName' => $resolve($params['processorName']) ?: $processorServiceId, - ]; - } else { - throw new \LogicException(sprintf( - 'Topic subscriber configuration is invalid. "%s"', - json_encode($processorClass::getSubscribedTopics()) - )); - } - } - } else { - foreach ($tagAttributes as $tagAttribute) { - $tagAttribute = array_replace($subscriptionPrototype, $tagAttribute); - - if (false == $tagAttribute['topicName']) { - throw new \LogicException(sprintf('Topic name is not set on message processor tag but it is required. Service %s', $processorServiceId)); - } - - $data[] = [ - 'topicName' => $resolve($tagAttribute['topicName']), - 'queueName' => $resolve($tagAttribute['queueName']) ?: $defaultQueueName, - 'processorName' => $resolve($tagAttribute['processorName']) ?: $processorServiceId, - ]; - } - } - - return $data; - } -} diff --git a/pkg/enqueue-bundle/DependencyInjection/Configuration.php b/pkg/enqueue-bundle/DependencyInjection/Configuration.php index 2cbbfa6cd..733849d35 100644 --- a/pkg/enqueue-bundle/DependencyInjection/Configuration.php +++ b/pkg/enqueue-bundle/DependencyInjection/Configuration.php @@ -2,62 +2,118 @@ namespace Enqueue\Bundle\DependencyInjection; -use Enqueue\Client\Config; -use Enqueue\Symfony\TransportFactoryInterface; +use Enqueue\AsyncCommand\RunCommandProcessor; +use Enqueue\AsyncEventDispatcher\DependencyInjection\AsyncEventDispatcherExtension; +use Enqueue\JobQueue\Job; +use Enqueue\Monitoring\Symfony\DependencyInjection\MonitoringFactory; +use Enqueue\Symfony\Client\DependencyInjection\ClientFactory; +use Enqueue\Symfony\DependencyInjection\TransportFactory; +use Enqueue\Symfony\MissingComponentFactory; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -class Configuration implements ConfigurationInterface +final class Configuration implements ConfigurationInterface { - /** - * @var TransportFactoryInterface[] - */ - private $factories; - - /** - * @param TransportFactoryInterface[] $factories - */ - public function __construct(array $factories) + private $debug; + + public function __construct(bool $debug) + { + $this->debug = $debug; + } + + public function getConfigTreeBuilder(): TreeBuilder + { + if (method_exists(TreeBuilder::class, 'getRootNode')) { + $tb = new TreeBuilder('enqueue'); + $rootNode = $tb->getRootNode(); + } else { + $tb = new TreeBuilder(); + $rootNode = $tb->root('enqueue'); + } + + $rootNode + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->arrayPrototype() + ->children() + ->append(TransportFactory::getConfiguration()) + ->append(TransportFactory::getQueueConsumerConfiguration()) + ->append(ClientFactory::getConfiguration($this->debug)) + ->append($this->getMonitoringConfiguration()) + ->append($this->getAsyncCommandsConfiguration()) + ->append($this->getJobConfiguration()) + ->append($this->getAsyncEventsConfiguration()) + ->arrayNode('extensions')->addDefaultsIfNotSet()->children() + ->booleanNode('doctrine_ping_connection_extension')->defaultFalse()->end() + ->booleanNode('doctrine_clear_identity_map_extension')->defaultFalse()->end() + ->booleanNode('doctrine_odm_clear_identity_map_extension')->defaultFalse()->end() + ->booleanNode('doctrine_closed_entity_manager_extension')->defaultFalse()->end() + ->booleanNode('reset_services_extension')->defaultFalse()->end() + ->booleanNode('signal_extension')->defaultValue(function_exists('pcntl_signal_dispatch'))->end() + ->booleanNode('reply_extension')->defaultTrue()->end() + ->end()->end() + ->end() + ->end() + ; + + return $tb; + } + + private function getMonitoringConfiguration(): ArrayNodeDefinition { - $this->factories = $factories; + if (false === class_exists(MonitoringFactory::class)) { + return MissingComponentFactory::getConfiguration('monitoring', ['enqueue/monitoring']); + } + + return MonitoringFactory::getConfiguration(); } - /** - * {@inheritdoc} - */ - public function getConfigTreeBuilder() + private function getAsyncCommandsConfiguration(): ArrayNodeDefinition { - $tb = new TreeBuilder(); - $rootNode = $tb->root('enqueue'); + if (false === class_exists(RunCommandProcessor::class)) { + return MissingComponentFactory::getConfiguration('async_commands', ['enqueue/async-command']); + } - $transportChildren = $rootNode->children() - ->arrayNode('transport')->isRequired()->children(); + return (new ArrayNodeDefinition('async_commands')) + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->integerNode('timeout')->min(0)->defaultValue(60)->end() + ->scalarNode('command_name')->defaultNull()->end() + ->scalarNode('queue_name')->defaultNull()->end() + ->end() + ->addDefaultsIfNotSet() + ->canBeEnabled() + ; + } - foreach ($this->factories as $factory) { - $factory->addConfiguration( - $transportChildren->arrayNode($factory->getName()) - ); + private function getJobConfiguration(): ArrayNodeDefinition + { + if (false === class_exists(Job::class)) { + return MissingComponentFactory::getConfiguration('job', ['enqueue/job-queue']); } - $rootNode->children() - ->arrayNode('client')->children() - ->booleanNode('traceable_producer')->defaultFalse()->end() - ->scalarNode('prefix')->defaultValue('enqueue')->end() - ->scalarNode('app_name')->defaultValue('app')->end() - ->scalarNode('router_topic')->defaultValue('router')->cannotBeEmpty()->end() - ->scalarNode('router_queue')->defaultValue(Config::DEFAULT_PROCESSOR_QUEUE_NAME)->cannotBeEmpty()->end() - ->scalarNode('router_processor')->defaultValue('enqueue.client.router_processor')->end() - ->scalarNode('default_processor_queue')->defaultValue(Config::DEFAULT_PROCESSOR_QUEUE_NAME)->cannotBeEmpty()->end() - ->integerNode('redelivered_delay_time')->min(0)->defaultValue(0)->end() - ->end()->end() - ->booleanNode('job')->defaultFalse()->end() - ->arrayNode('extensions')->addDefaultsIfNotSet()->children() - ->booleanNode('doctrine_ping_connection_extension')->defaultFalse()->end() - ->booleanNode('doctrine_clear_identity_map_extension')->defaultFalse()->end() - ->booleanNode('signal_extension')->defaultValue(function_exists('pcntl_signal_dispatch'))->end() - ->end()->end() + return (new ArrayNodeDefinition('job')) + ->children() + ->booleanNode('default_mapping') + ->defaultTrue() + ->info('Adds bundle\'s default Job entity mapping to application\'s entity manager') + ->end() + ->end() + ->addDefaultsIfNotSet() + ->canBeEnabled() ; + } - return $tb; + private function getAsyncEventsConfiguration(): ArrayNodeDefinition + { + if (false == class_exists(AsyncEventDispatcherExtension::class)) { + return MissingComponentFactory::getConfiguration('async_events', ['enqueue/async-event-dispatcher']); + } + + return (new ArrayNodeDefinition('async_events')) + ->addDefaultsIfNotSet() + ->canBeEnabled() + ; } } diff --git a/pkg/enqueue-bundle/DependencyInjection/EnqueueExtension.php b/pkg/enqueue-bundle/DependencyInjection/EnqueueExtension.php index 86610e44d..96fca6fde 100644 --- a/pkg/enqueue-bundle/DependencyInjection/EnqueueExtension.php +++ b/pkg/enqueue-bundle/DependencyInjection/EnqueueExtension.php @@ -2,133 +2,424 @@ namespace Enqueue\Bundle\DependencyInjection; -use Enqueue\Client\TraceableMessageProducer; +use Enqueue\AsyncCommand\DependencyInjection\AsyncCommandExtension; +use Enqueue\AsyncEventDispatcher\DependencyInjection\AsyncEventDispatcherExtension; +use Enqueue\Bundle\Consumption\Extension\DoctrineClearIdentityMapExtension; +use Enqueue\Bundle\Consumption\Extension\DoctrineClosedEntityManagerExtension; +use Enqueue\Bundle\Consumption\Extension\DoctrinePingConnectionExtension; +use Enqueue\Bundle\Consumption\Extension\ResetServicesExtension; +use Enqueue\Bundle\Profiler\MessageQueueCollector; +use Enqueue\Client\CommandSubscriberInterface; +use Enqueue\Client\TopicSubscriberInterface; +use Enqueue\Consumption\Extension\ReplyExtension; +use Enqueue\Consumption\Extension\SignalExtension; use Enqueue\JobQueue\Job; -use Enqueue\Symfony\TransportFactoryInterface; +use Enqueue\Monitoring\Symfony\DependencyInjection\MonitoringFactory; +use Enqueue\Symfony\Client\DependencyInjection\ClientFactory; +use Enqueue\Symfony\DependencyInjection\TransportFactory; +use Enqueue\Symfony\DiUtils; +use Interop\Queue\Context; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; -class EnqueueExtension extends Extension +final class EnqueueExtension extends Extension implements PrependExtensionInterface { - /** - * @var TransportFactoryInterface[] - */ - private $factories; + public function load(array $configs, ContainerBuilder $container): void + { + $config = $this->processConfiguration($this->getConfiguration($configs, $container), $configs); + + $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.yml'); + + // find default configuration + $defaultName = null; + foreach ($config as $name => $modules) { + // set first as default + if (null === $defaultName) { + $defaultName = $name; + } + + // or with name 'default' + if (DiUtils::DEFAULT_CONFIG === $name) { + $defaultName = $name; + } + } + + $transportNames = []; + $clientNames = []; + foreach ($config as $name => $modules) { + // transport & consumption + $transportNames[] = $name; + + $transportFactory = (new TransportFactory($name, $defaultName === $name)); + $transportFactory->buildConnectionFactory($container, $modules['transport']); + $transportFactory->buildContext($container, []); + $transportFactory->buildQueueConsumer($container, $modules['consumption']); + $transportFactory->buildRpcClient($container, []); + + // client + if (isset($modules['client'])) { + $clientNames[] = $name; + + $clientConfig = $modules['client']; + // todo + $clientConfig['transport'] = $modules['transport']; + $clientConfig['consumption'] = $modules['consumption']; + + $clientFactory = new ClientFactory($name, $defaultName === $name); + $clientFactory->build($container, $clientConfig); + $clientFactory->createDriver($container, $modules['transport']); + $clientFactory->createFlushSpoolProducerListener($container); + } + + // monitoring + if (isset($modules['monitoring'])) { + $monitoringFactory = new MonitoringFactory($name); + $monitoringFactory->buildStorage($container, $modules['monitoring']); + $monitoringFactory->buildConsumerExtension($container, $modules['monitoring']); + + if (isset($modules['client'])) { + $monitoringFactory->buildClientExtension($container, $modules['monitoring']); + } + } + + // job-queue + if (false == empty($modules['job']['enabled'])) { + if (false === isset($modules['client'])) { + throw new \LogicException('Client is required for job-queue.'); + } + + if ($name !== $defaultName) { + throw new \LogicException('Job-queue supports only default configuration.'); + } + + $loader->load('job.yml'); + } + + // async events + if (false == empty($modules['async_events']['enabled'])) { + if ($name !== $defaultName) { + throw new \LogicException('Async events supports only default configuration.'); + } + + $extension = new AsyncEventDispatcherExtension(); + $extension->load([[ + 'context_service' => Context::class, + ]], $container); + } + } + + $defaultClient = null; + if (in_array($defaultName, $clientNames, true)) { + $defaultClient = $defaultName; + } + + $container->setParameter('enqueue.transports', $transportNames); + $container->setParameter('enqueue.clients', $clientNames); - public function __construct() + $container->setParameter('enqueue.default_transport', $defaultName); + + if ($defaultClient) { + $container->setParameter('enqueue.default_client', $defaultClient); + } + + if ($defaultClient) { + $this->setupAutowiringForDefaultClientsProcessors($container, $defaultClient); + } + + $this->loadMessageQueueCollector($config, $container); + $this->loadAsyncCommands($config, $container); + + // extensions + $this->loadDoctrinePingConnectionExtension($config, $container); + $this->loadDoctrineClearIdentityMapExtension($config, $container); + $this->loadDoctrineOdmClearIdentityMapExtension($config, $container); + $this->loadDoctrineClosedEntityManagerExtension($config, $container); + $this->loadResetServicesExtension($config, $container); + $this->loadSignalExtension($config, $container); + $this->loadReplyExtension($config, $container); + } + + public function getConfiguration(array $config, ContainerBuilder $container): Configuration { - $this->factories = []; + $rc = new \ReflectionClass(Configuration::class); + + $container->addResource(new FileResource($rc->getFileName())); + + return new Configuration($container->getParameter('kernel.debug')); } - /** - * @param TransportFactoryInterface $transportFactory - */ - public function addTransportFactory(TransportFactoryInterface $transportFactory) + public function prepend(ContainerBuilder $container): void { - $name = $transportFactory->getName(); + $this->registerJobQueueDoctrineEntityMapping($container); + } - if (empty($name)) { - throw new \LogicException('Transport factory name cannot be empty'); + private function registerJobQueueDoctrineEntityMapping(ContainerBuilder $container) + { + if (!class_exists(Job::class)) { + return; } - if (array_key_exists($name, $this->factories)) { - throw new \LogicException(sprintf('Transport factory with such name already added. Name %s', $name)); + + $bundles = $container->getParameter('kernel.bundles'); + + if (!isset($bundles['DoctrineBundle'])) { + return; } - $this->factories[$name] = $transportFactory; + $config = $container->getExtensionConfig('enqueue'); + + if (!empty($config)) { + $processedConfig = $this->processConfiguration(new Configuration(false), $config); + + foreach ($processedConfig as $name => $modules) { + if (isset($modules['job']) && false === $modules['job']['default_mapping']) { + return; + } + } + } + + foreach ($container->getExtensionConfig('doctrine') as $config) { + // do not register mappings if dbal not configured. + if (!empty($config['dbal'])) { + $rc = new \ReflectionClass(Job::class); + $jobQueueRootDir = dirname($rc->getFileName()); + $container->prependExtensionConfig('doctrine', [ + 'orm' => [ + 'mappings' => [ + 'enqueue_job_queue' => [ + 'is_bundle' => false, + 'type' => 'xml', + 'dir' => $jobQueueRootDir.'/Doctrine/mapping', + 'prefix' => 'Enqueue\JobQueue\Doctrine\Entity', + ], + ], + ], + ]); + break; + } + } } - /** - * {@inheritdoc} - */ - public function load(array $configs, ContainerBuilder $container) + private function setupAutowiringForDefaultClientsProcessors(ContainerBuilder $container, string $defaultClient) { - $config = $this->processConfiguration(new Configuration($this->factories), $configs); + $container->registerForAutoconfiguration(TopicSubscriberInterface::class) + ->setPublic(true) + ->addTag('enqueue.topic_subscriber', ['client' => $defaultClient]) + ; - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.yml'); + $container->registerForAutoconfiguration(CommandSubscriberInterface::class) + ->setPublic(true) + ->addTag('enqueue.command_subscriber', ['client' => $defaultClient]) + ; + } + + private function loadDoctrinePingConnectionExtension(array $config, ContainerBuilder $container): void + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['doctrine_ping_connection_extension']) { + $configNames[] = $name; + } + } - foreach ($config['transport'] as $name => $transportConfig) { - $this->factories[$name]->createContext($container, $transportConfig); + if ([] === $configNames) { + return; } - if (isset($config['client'])) { - $loader->load('client.yml'); + $extension = $container->register('enqueue.consumption.doctrine_ping_connection_extension', DoctrinePingConnectionExtension::class) + ->addArgument(new Reference('doctrine')) + ; + + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } - foreach ($config['transport'] as $name => $transportConfig) { - $this->factories[$name]->createDriver($container, $transportConfig); + private function loadDoctrineClearIdentityMapExtension(array $config, ContainerBuilder $container): void + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['doctrine_clear_identity_map_extension']) { + $configNames[] = $name; } + } + + if ([] === $configNames) { + return; + } - if (false == isset($config['transport'][$config['transport']['default']['alias']])) { - throw new \LogicException(sprintf('Transport is not enabled: %s', $config['transport']['default']['alias'])); + $extension = $container->register('enqueue.consumption.doctrine_clear_identity_map_extension', DoctrineClearIdentityMapExtension::class) + ->addArgument(new Reference('doctrine')) + ; + + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } + + private function loadDoctrineOdmClearIdentityMapExtension(array $config, ContainerBuilder $container): void + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['doctrine_odm_clear_identity_map_extension']) { + $configNames[] = $name; } + } - $configDef = $container->getDefinition('enqueue.client.config'); - $configDef->setArguments([ - $config['client']['prefix'], - $config['client']['app_name'], - $config['client']['router_topic'], - $config['client']['router_queue'], - $config['client']['default_processor_queue'], - $config['client']['router_processor'], - $config['transport'][$config['transport']['default']['alias']], - ]); + if ([] === $configNames) { + return; + } - $container->setParameter('enqueue.client.router_queue_name', $config['client']['router_queue']); - $container->setParameter('enqueue.client.default_queue_name', $config['client']['default_processor_queue']); + $extension = $container->register('enqueue.consumption.doctrine_odm_clear_identity_map_extension', DoctrineClearIdentityMapExtension::class) + ->addArgument(new Reference('doctrine_mongodb')) + ; - if (false == empty($config['client']['traceable_producer'])) { - $producerId = 'enqueue.client.traceable_message_producer'; - $container->register($producerId, TraceableMessageProducer::class) - ->setDecoratedService('enqueue.client.message_producer') - ->addArgument(new Reference('enqueue.client.traceable_message_producer.inner')) - ; + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } + + private function loadDoctrineClosedEntityManagerExtension(array $config, ContainerBuilder $container) + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['doctrine_closed_entity_manager_extension']) { + $configNames[] = $name; + } + } + + if ([] === $configNames) { + return; + } + + $extension = $container->register('enqueue.consumption.doctrine_closed_entity_manager_extension', DoctrineClosedEntityManagerExtension::class) + ->addArgument(new Reference('doctrine')); + + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } + + private function loadResetServicesExtension(array $config, ContainerBuilder $container) + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['reset_services_extension']) { + $configNames[] = $name; } + } + + if ([] === $configNames) { + return; + } + + $extension = $container->register('enqueue.consumption.reset_services_extension', ResetServicesExtension::class) + ->addArgument(new Reference('services_resetter')); - if ($config['client']['redelivered_delay_time']) { - $loader->load('extensions/delay_redelivered_message_extension.yml'); + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } - $container->getDefinition('enqueue.client.delay_redelivered_message_extension') - ->replaceArgument(1, $config['client']['redelivered_delay_time']) - ; + private function loadSignalExtension(array $config, ContainerBuilder $container): void + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['signal_extension']) { + $configNames[] = $name; } } - if ($config['job']) { - if (false == class_exists(Job::class)) { - throw new \LogicException('Seems "enqueue/job-queue" is not installed. Please fix this issue.'); + if ([] === $configNames) { + return; + } + + $extension = $container->register('enqueue.consumption.signal_extension', SignalExtension::class); + + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); + } + } + + private function loadReplyExtension(array $config, ContainerBuilder $container): void + { + $configNames = []; + foreach ($config as $name => $modules) { + if ($modules['extensions']['reply_extension']) { + $configNames[] = $name; } + } - $loader->load('job.yml'); + if ([] === $configNames) { + return; } - if ($config['extensions']['doctrine_ping_connection_extension']) { - $loader->load('extensions/doctrine_ping_connection_extension.yml'); + $extension = $container->register('enqueue.consumption.reply_extension', ReplyExtension::class); + + foreach ($configNames as $name) { + $extension->addTag('enqueue.consumption_extension', ['client' => $name]); + $extension->addTag('enqueue.transport.consumption_extension', ['transport' => $name]); } + } - if ($config['extensions']['doctrine_clear_identity_map_extension']) { - $loader->load('extensions/doctrine_clear_identity_map_extension.yml'); + private function loadAsyncCommands(array $config, ContainerBuilder $container): void + { + $configs = []; + foreach ($config as $name => $modules) { + if (false === empty($modules['async_commands']['enabled'])) { + $configs[] = [ + 'name' => $name, + 'timeout' => $modules['async_commands']['timeout'], + 'command_name' => $modules['async_commands']['command_name'], + 'queue_name' => $modules['async_commands']['queue_name'], + ]; + } } - if ($config['extensions']['signal_extension']) { - $loader->load('extensions/signal_extension.yml'); + if (false == $configs) { + return; } + + if (false == class_exists(AsyncCommandExtension::class)) { + throw new \LogicException('The "enqueue/async-command" package has to be installed.'); + } + + $extension = new AsyncCommandExtension(); + $extension->load(['clients' => $configs], $container); } - /** - * {@inheritdoc} - * - * @return Configuration - */ - public function getConfiguration(array $config, ContainerBuilder $container) + private function loadMessageQueueCollector(array $config, ContainerBuilder $container) { - $rc = new \ReflectionClass(Configuration::class); + $configNames = []; + foreach ($config as $name => $modules) { + if (isset($modules['client'])) { + $configNames[] = $name; + } + } - $container->addResource(new FileResource($rc->getFileName())); + if (false == $configNames) { + return; + } + + $service = $container->register('enqueue.profiler.message_queue_collector', MessageQueueCollector::class); + $service->addTag('data_collector', [ + 'template' => '@Enqueue/Profiler/panel.html.twig', + 'id' => 'enqueue.message_queue', + ]); - return new Configuration($this->factories); + foreach ($configNames as $configName) { + $service->addMethodCall('addProducer', [$configName, DiUtils::create('client', $configName)->reference('producer')]); + } } } diff --git a/pkg/enqueue-bundle/EnqueueBundle.php b/pkg/enqueue-bundle/EnqueueBundle.php index e07191358..5010ba0ed 100644 --- a/pkg/enqueue-bundle/EnqueueBundle.php +++ b/pkg/enqueue-bundle/EnqueueBundle.php @@ -2,49 +2,45 @@ namespace Enqueue\Bundle; -use Enqueue\AmqpExt\AmqpContext; -use Enqueue\AmqpExt\Symfony\AmqpTransportFactory; -use Enqueue\AmqpExt\Symfony\RabbitMqTransportFactory; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildClientRoutingPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildExtensionsPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildProcessorRegistryPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildQueueMetaRegistryPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildTopicMetaSubscribersPass; -use Enqueue\Bundle\DependencyInjection\EnqueueExtension; -use Enqueue\Stomp\StompContext; -use Enqueue\Stomp\Symfony\RabbitMqStompTransportFactory; -use Enqueue\Stomp\Symfony\StompTransportFactory; -use Enqueue\Symfony\DefaultTransportFactory; -use Enqueue\Symfony\NullTransportFactory; +use Enqueue\AsyncEventDispatcher\DependencyInjection\AsyncEventDispatcherExtension; +use Enqueue\AsyncEventDispatcher\DependencyInjection\AsyncEventsPass; +use Enqueue\AsyncEventDispatcher\DependencyInjection\AsyncTransformersPass; +use Enqueue\Doctrine\DoctrineSchemaCompilerPass; +use Enqueue\Symfony\Client\DependencyInjection\AnalyzeRouteCollectionPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildClientExtensionsPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildCommandSubscriberRoutesPass as BuildClientCommandSubscriberRoutesPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildConsumptionExtensionsPass as BuildClientConsumptionExtensionsPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildProcessorRegistryPass as BuildClientProcessorRegistryPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildProcessorRoutesPass as BuildClientProcessorRoutesPass; +use Enqueue\Symfony\Client\DependencyInjection\BuildTopicSubscriberRoutesPass as BuildClientTopicSubscriberRoutesPass; +use Enqueue\Symfony\DependencyInjection\BuildConsumptionExtensionsPass; +use Enqueue\Symfony\DependencyInjection\BuildProcessorRegistryPass; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class EnqueueBundle extends Bundle { - /** - * {@inheritdoc} - */ - public function build(ContainerBuilder $container) + public function build(ContainerBuilder $container): void { - $container->addCompilerPass(new BuildExtensionsPass()); - $container->addCompilerPass(new BuildClientRoutingPass()); + // transport passes + $container->addCompilerPass(new BuildConsumptionExtensionsPass()); $container->addCompilerPass(new BuildProcessorRegistryPass()); - $container->addCompilerPass(new BuildTopicMetaSubscribersPass()); - $container->addCompilerPass(new BuildQueueMetaRegistryPass()); - /** @var EnqueueExtension $extension */ - $extension = $container->getExtension('enqueue'); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new NullTransportFactory()); + // client passes + $container->addCompilerPass(new BuildClientConsumptionExtensionsPass()); + $container->addCompilerPass(new BuildClientExtensionsPass()); + $container->addCompilerPass(new BuildClientTopicSubscriberRoutesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100); + $container->addCompilerPass(new BuildClientCommandSubscriberRoutesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100); + $container->addCompilerPass(new BuildClientProcessorRoutesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100); + $container->addCompilerPass(new AnalyzeRouteCollectionPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 30); + $container->addCompilerPass(new BuildClientProcessorRegistryPass()); - if (class_exists(StompContext::class)) { - $extension->addTransportFactory(new StompTransportFactory()); - $extension->addTransportFactory(new RabbitMqStompTransportFactory()); + if (class_exists(AsyncEventDispatcherExtension::class)) { + $container->addCompilerPass(new AsyncEventsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100); + $container->addCompilerPass(new AsyncTransformersPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100); } - if (class_exists(AmqpContext::class)) { - $extension->addTransportFactory(new AmqpTransportFactory()); - $extension->addTransportFactory(new RabbitMqTransportFactory()); - } + $container->addCompilerPass(new DoctrineSchemaCompilerPass()); } } diff --git a/pkg/enqueue-bundle/Entity/Job.php b/pkg/enqueue-bundle/Entity/Job.php deleted file mode 100644 index 38abf755e..000000000 --- a/pkg/enqueue-bundle/Entity/Job.php +++ /dev/null @@ -1,108 +0,0 @@ -childJobs = new ArrayCollection(); - } -} diff --git a/pkg/enqueue-bundle/Entity/JobUnique.php b/pkg/enqueue-bundle/Entity/JobUnique.php deleted file mode 100644 index 3c7426bb9..000000000 --- a/pkg/enqueue-bundle/Entity/JobUnique.php +++ /dev/null @@ -1,18 +0,0 @@ -producers[$name] = $producer; + } + + public function getCount(): int + { + $count = 0; + foreach ($this->data as $name => $messages) { + $count += count($messages); + } + + return $count; + } + + /** + * @return array + */ + public function getSentMessages() + { + return $this->data; + } + + /** + * @param string $priority + * + * @return string + */ + public function prettyPrintPriority($priority) + { + $map = [ + MessagePriority::VERY_LOW => 'very low', + MessagePriority::LOW => 'low', + MessagePriority::NORMAL => 'normal', + MessagePriority::HIGH => 'high', + MessagePriority::VERY_HIGH => 'very high', + ]; + + return isset($map[$priority]) ? $map[$priority] : $priority; + } + + /** + * @return string + */ + public function ensureString($body) + { + return is_string($body) ? $body : JSON::encode($body); + } + + public function getName(): string + { + return 'enqueue.message_queue'; + } + + public function reset(): void + { + $this->data = []; + } + + protected function collectInternal(Request $request, Response $response): void + { + $this->data = []; + + foreach ($this->producers as $name => $producer) { + if ($producer instanceof TraceableProducer) { + $this->data[$name] = $producer->getTraces(); + } + } + } +} diff --git a/pkg/enqueue-bundle/Profiler/MessageQueueCollector.php b/pkg/enqueue-bundle/Profiler/MessageQueueCollector.php index 622d2c485..3c484a7d1 100644 --- a/pkg/enqueue-bundle/Profiler/MessageQueueCollector.php +++ b/pkg/enqueue-bundle/Profiler/MessageQueueCollector.php @@ -2,89 +2,13 @@ namespace Enqueue\Bundle\Profiler; -use Enqueue\Client\MessagePriority; -use Enqueue\Client\MessageProducerInterface; -use Enqueue\Client\TraceableMessageProducer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\DataCollector\DataCollector; -class MessageQueueCollector extends DataCollector +class MessageQueueCollector extends AbstractMessageQueueCollector { - /** - * @var MessageProducerInterface - */ - private $messageProducer; - - /** - * @param MessageProducerInterface $messageProducer - */ - public function __construct(MessageProducerInterface $messageProducer) - { - $this->messageProducer = $messageProducer; - } - - /** - * {@inheritdoc} - */ - public function collect(Request $request, Response $response, \Exception $exception = null) - { - $this->data = [ - 'sent_messages' => [], - ]; - - if ($this->messageProducer instanceof TraceableMessageProducer) { - $this->data['sent_messages'] = $this->messageProducer->getTraces(); - } - } - - /** - * @return array - */ - public function getSentMessages() - { - return $this->data['sent_messages']; - } - - /** - * @param string $priority - * - * @return string - */ - public function prettyPrintPriority($priority) - { - $map = [ - MessagePriority::VERY_LOW => 'very low', - MessagePriority::LOW => 'low', - MessagePriority::NORMAL => 'normal', - MessagePriority::HIGH => 'high', - MessagePriority::VERY_HIGH => 'very high', - ]; - - return isset($map[$priority]) ? $map[$priority] : $priority; - } - - /** - * @param string $message - * - * @return string - */ - public function prettyPrintMessage($message) - { - if (is_scalar($message)) { - return htmlspecialchars($message); - } - - return htmlspecialchars( - json_encode($message, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) - ); - } - - /** - * {@inheritdoc} - */ - public function getName() + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void { - return 'enqueue.message_queue'; + $this->collectInternal($request, $response); } } diff --git a/pkg/enqueue-bundle/README.md b/pkg/enqueue-bundle/README.md index b1adcea0d..2b8bbfe68 100644 --- a/pkg/enqueue-bundle/README.md +++ b/pkg/enqueue-bundle/README.md @@ -1,18 +1,37 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + # Message Queue Bundle [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/enqueue-bundle.png?branch=master)](https://travis-ci.org/php-enqueue/enqueue-bundle) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/enqueue-bundle/ci.yml?branch=master)](https://github.com/php-enqueue/enqueue-bundle/actions?query=workflow%3ACI) [![Total Downloads](https://poser.pugx.org/enqueue/enqueue-bundle/d/total.png)](https://packagist.org/packages/enqueue/enqueue-bundle) [![Latest Stable Version](https://poser.pugx.org/enqueue/enqueue-bundle/version.png)](https://packagist.org/packages/enqueue/enqueue-bundle) - -Integrates message queue components to Symfony application. + +Integrates message queue components to Symfony application. ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Quick tour](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/bundle/quick_tour.md) +* [Documentation](https://php-enqueue.github.io/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/enqueue-bundle/Resources/config/client.yml b/pkg/enqueue-bundle/Resources/config/client.yml deleted file mode 100644 index ffcb806e0..000000000 --- a/pkg/enqueue-bundle/Resources/config/client.yml +++ /dev/null @@ -1,111 +0,0 @@ -services: - enqueue.client.config: - class: 'Enqueue\Client\Config' - public: false - - enqueue.client.message_producer: - class: 'Enqueue\Client\MessageProducer' - arguments: ['@enqueue.client.driver'] - - enqueue.message_producer: - alias: 'enqueue.client.message_producer' - - enqueue.client.router_processor: - class: 'Enqueue\Client\RouterProcessor' - public: true - arguments: - - '@enqueue.client.driver' - - [] - tags: - - - name: 'enqueue.client.processor' - topicName: '__router__' - queueName: '%enqueue.client.router_queue_name%' - - enqueue.client.processor_registry: - class: 'Enqueue\Symfony\Client\ContainerAwareProcessorRegistry' - public: false - calls: - - ['setContainer', ['@service_container']] - - enqueue.client.meta.topic_meta_registry: - class: 'Enqueue\Client\Meta\TopicMetaRegistry' - public: true - arguments: [[]] - - enqueue.client.meta.queue_meta_registry: - class: 'Enqueue\Client\Meta\QueueMetaRegistry' - public: true - arguments: ['@enqueue.client.config', []] - - enqueue.client.delegate_processor: - class: 'Enqueue\Client\DelegateProcessor' - public: false - arguments: - - '@enqueue.client.processor_registry' - - enqueue.client.extension.set_router_properties: - class: 'Enqueue\Client\ConsumptionExtension\SetRouterPropertiesExtension' - public: false - arguments: - - '@enqueue.client.driver' - tags: - - { name: 'enqueue.consumption.extension', priority: 5 } - - enqueue.client.queue_consumer: - class: 'Enqueue\Consumption\QueueConsumer' - public: false - arguments: - - '@enqueue.transport.context' - - '@enqueue.consumption.extensions' - - enqueue.client.consume_messages_command: - class: 'Enqueue\Symfony\Client\ConsumeMessagesCommand' - public: true - arguments: - - '@enqueue.client.queue_consumer' - - '@enqueue.client.delegate_processor' - - '@enqueue.client.meta.queue_meta_registry' - - '@enqueue.client.driver' - tags: - - { name: 'console.command' } - - enqueue.client.produce_message_command: - class: 'Enqueue\Symfony\Client\ProduceMessageCommand' - public: true - arguments: - - '@enqueue.client.message_producer' - tags: - - { name: 'console.command' } - - enqueue.client.meta.topics_command: - class: 'Enqueue\Symfony\Client\Meta\TopicsCommand' - arguments: - - '@enqueue.client.meta.topic_meta_registry' - tags: - - { name: 'console.command' } - - enqueue.client.meta.queues_command: - class: 'Enqueue\Symfony\Client\Meta\QueuesCommand' - arguments: - - '@enqueue.client.meta.queue_meta_registry' - tags: - - { name: 'console.command' } - - enqueue.client.setup_broker_command: - class: 'Enqueue\Symfony\Client\SetupBrokerCommand' - public: true - arguments: - - '@enqueue.client.driver' - tags: - - { name: 'console.command' } - - enqueue.profiler.message_queue_collector: - class: 'Enqueue\Bundle\Profiler\MessageQueueCollector' - public: false - arguments: ['@enqueue.message_producer'] - tags: - - - name: 'data_collector' - template: 'EnqueueBundle:Profiler:panel.html.twig' - id: 'enqueue.message_queue' diff --git a/pkg/enqueue-bundle/Resources/config/extensions/delay_redelivered_message_extension.yml b/pkg/enqueue-bundle/Resources/config/extensions/delay_redelivered_message_extension.yml deleted file mode 100644 index 24fff7eb0..000000000 --- a/pkg/enqueue-bundle/Resources/config/extensions/delay_redelivered_message_extension.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - enqueue.client.delay_redelivered_message_extension: - class: 'Enqueue\Client\ConsumptionExtension\DelayRedeliveredMessageExtension' - public: false - arguments: - - '@enqueue.client.driver' - - ~ - tags: - - { name: 'enqueue.consumption.extension', priority: 10 } \ No newline at end of file diff --git a/pkg/enqueue-bundle/Resources/config/extensions/doctrine_clear_identity_map_extension.yml b/pkg/enqueue-bundle/Resources/config/extensions/doctrine_clear_identity_map_extension.yml deleted file mode 100644 index 932cb9ba1..000000000 --- a/pkg/enqueue-bundle/Resources/config/extensions/doctrine_clear_identity_map_extension.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - enqueue.consumption.doctrine_clear_identity_map_extension: - class: 'Enqueue\Bundle\Consumption\Extension\DoctrineClearIdentityMapExtension' - public: false - arguments: - - '@doctrine' - tags: - - { name: 'enqueue.consumption.extension' } diff --git a/pkg/enqueue-bundle/Resources/config/extensions/doctrine_ping_connection_extension.yml b/pkg/enqueue-bundle/Resources/config/extensions/doctrine_ping_connection_extension.yml deleted file mode 100644 index 7b3383a79..000000000 --- a/pkg/enqueue-bundle/Resources/config/extensions/doctrine_ping_connection_extension.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - enqueue.consumption.doctrine_ping_connection_extension: - class: 'Enqueue\Bundle\Consumption\Extension\DoctrinePingConnectionExtension' - public: false - arguments: - - '@doctrine' - tags: - - { name: 'enqueue.consumption.extension' } diff --git a/pkg/enqueue-bundle/Resources/config/extensions/signal_extension.yml b/pkg/enqueue-bundle/Resources/config/extensions/signal_extension.yml deleted file mode 100644 index e7609eb06..000000000 --- a/pkg/enqueue-bundle/Resources/config/extensions/signal_extension.yml +++ /dev/null @@ -1,6 +0,0 @@ -services: - enqueue.consumption.signal_extension: - class: 'Enqueue\Consumption\Extension\SignalExtension' - public: false - tags: - - { name: 'enqueue.consumption.extension' } \ No newline at end of file diff --git a/pkg/enqueue-bundle/Resources/config/job.yml b/pkg/enqueue-bundle/Resources/config/job.yml index 4a05ce0c3..0a368aebc 100644 --- a/pkg/enqueue-bundle/Resources/config/job.yml +++ b/pkg/enqueue-bundle/Resources/config/job.yml @@ -2,49 +2,91 @@ parameters: enqueue.job.unique_job_table_name: 'enqueue_job_unique' services: - enqueue.job.storage: - class: 'Enqueue\JobQueue\JobStorage' + Enqueue\JobQueue\Doctrine\JobStorage: + class: 'Enqueue\JobQueue\Doctrine\JobStorage' + public: true arguments: - '@doctrine' - - 'Enqueue\Bundle\Entity\Job' + - 'Enqueue\JobQueue\Doctrine\Entity\Job' - '%enqueue.job.unique_job_table_name%' - enqueue.job.processor: + # Deprecated. To be removed in 0.10. + enqueue.job.storage: + public: true + alias: 'Enqueue\JobQueue\Doctrine\JobStorage' + + Enqueue\JobQueue\JobProcessor: class: 'Enqueue\JobQueue\JobProcessor' + public: true arguments: - - '@enqueue.job.storage' - - '@enqueue.client.message_producer' + - '@Enqueue\JobQueue\Doctrine\JobStorage' + - '@Enqueue\Client\ProducerInterface' - enqueue.job.runner: + # Deprecated. To be removed in 0.10. + enqueue.job.processor: + public: true + alias: 'Enqueue\JobQueue\JobProcessor' + + Enqueue\JobQueue\JobRunner: class: 'Enqueue\JobQueue\JobRunner' + public: true arguments: - - '@enqueue.job.processor' + - '@Enqueue\JobQueue\JobProcessor' - enqueue.job.calculate_root_job_status_service: + # Deprecated. To be removed in 0.10. + enqueue.job.runner: + public: true + alias: 'Enqueue\JobQueue\JobRunner' + + Enqueue\JobQueue\CalculateRootJobStatusService: class: 'Enqueue\JobQueue\CalculateRootJobStatusService' + public: true arguments: - - '@enqueue.job.storage' + - '@Enqueue\JobQueue\Doctrine\JobStorage' - enqueue.job.calculate_root_job_status_processor: + # Deprecated. To be removed in 0.10. + enqueue.job.calculate_root_job_status_service: + public: true + alias: 'Enqueue\JobQueue\CalculateRootJobStatusService' + + Enqueue\JobQueue\CalculateRootJobStatusProcessor: class: 'Enqueue\JobQueue\CalculateRootJobStatusProcessor' + public: true arguments: - - '@enqueue.job.storage' - - '@enqueue.job.calculate_root_job_status_service' - - '@enqueue.client.message_producer' + - '@Enqueue\JobQueue\Doctrine\JobStorage' + - '@Enqueue\JobQueue\CalculateRootJobStatusService' + - '@Enqueue\Client\ProducerInterface' - '@logger' tags: - - { name: 'enqueue.client.processor' } + - { name: 'enqueue.command_subscriber', client: 'default' } - enqueue.job.dependent_job_processor: + # Deprecated. To be removed in 0.10. + enqueue.job.calculate_root_job_status_processor: + public: true + alias: 'Enqueue\JobQueue\CalculateRootJobStatusProcessor' + + Enqueue\JobQueue\DependentJobProcessor: class: 'Enqueue\JobQueue\DependentJobProcessor' + public: true arguments: - - '@enqueue.job.storage' - - '@enqueue.client.message_producer' + - '@Enqueue\JobQueue\Doctrine\JobStorage' + - '@Enqueue\Client\ProducerInterface' - '@logger' tags: - - { name: 'enqueue.client.processor' } + - { name: 'enqueue.topic_subscriber', client: 'default' } - enqueue.job.dependent_job_service: + # Deprecated. To be removed in 0.10. + enqueue.job.dependent_job_processor: + public: true + alias: 'Enqueue\JobQueue\DependentJobProcessor' + + Enqueue\JobQueue\DependentJobService: class: 'Enqueue\JobQueue\DependentJobService' + public: true arguments: - - '@enqueue.job.storage' + - '@Enqueue\JobQueue\Doctrine\JobStorage' + + # Deprecated. To be removed in 0.10. + enqueue.job.dependent_job_service: + public: true + alias: 'Enqueue\JobQueue\DependentJobService' diff --git a/pkg/enqueue-bundle/Resources/config/services.yml b/pkg/enqueue-bundle/Resources/config/services.yml index 63f608f1c..a207569c0 100644 --- a/pkg/enqueue-bundle/Resources/config/services.yml +++ b/pkg/enqueue-bundle/Resources/config/services.yml @@ -1,19 +1,54 @@ services: - enqueue.consumption.extensions: - class: 'Enqueue\Consumption\ChainExtension' - public: false + enqueue.locator: + class: 'Symfony\Component\DependencyInjection\ServiceLocator' arguments: - [] + tags: ['container.service_locator'] - enqueue.consumption.queue_consumer: - class: 'Enqueue\Consumption\QueueConsumer' + enqueue.transport.consume_command: + class: 'Enqueue\Symfony\Consumption\ConfigurableConsumeCommand' arguments: - - '@enqueue.transport.context' - - '@enqueue.consumption.extensions' + - '@enqueue.locator' + - '%enqueue.default_transport%' + - 'enqueue.transport.%s.queue_consumer' + - 'enqueue.transport.%s.processor_registry' + tags: + - { name: 'console.command' } + + enqueue.client.consume_command: + class: 'Enqueue\Symfony\Client\ConsumeCommand' + arguments: + - '@enqueue.locator' + - '%enqueue.default_client%' + - 'enqueue.client.%s.queue_consumer' + - 'enqueue.client.%s.driver' + - 'enqueue.client.%s.delegate_processor' + tags: + - { name: 'console.command' } + + enqueue.client.produce_command: + class: 'Enqueue\Symfony\Client\ProduceCommand' + arguments: + - '@enqueue.locator' + - '%enqueue.default_client%' + - 'enqueue.client.%s.producer' + tags: + - { name: 'console.command' } + + enqueue.client.setup_broker_command: + class: 'Enqueue\Symfony\Client\SetupBrokerCommand' + arguments: + - '@enqueue.locator' + - '%enqueue.default_client%' + - 'enqueue.client.%s.driver' + tags: + - { name: 'console.command' } - enqueue.command.consume_messages: - class: 'Enqueue\Symfony\Consumption\ContainerAwareConsumeMessagesCommand' + enqueue.client.routes_command: + class: 'Enqueue\Symfony\Client\RoutesCommand' arguments: - - '@enqueue.consumption.queue_consumer' + - '@enqueue.locator' + - '%enqueue.default_client%' + - 'enqueue.client.%s.driver' tags: - { name: 'console.command' } diff --git a/pkg/enqueue-bundle/Resources/views/Icon/icon.svg b/pkg/enqueue-bundle/Resources/views/Icon/icon.svg new file mode 100644 index 000000000..fc7b60cc9 --- /dev/null +++ b/pkg/enqueue-bundle/Resources/views/Icon/icon.svg @@ -0,0 +1,36 @@ + + + + + + Layer 1 + + + + + + + + + + + + + Layer 2 + + + + background + + + \ No newline at end of file diff --git a/pkg/enqueue-bundle/Resources/views/Profiler/panel.html.twig b/pkg/enqueue-bundle/Resources/views/Profiler/panel.html.twig index 835c0df49..ddd52a1e9 100644 --- a/pkg/enqueue-bundle/Resources/views/Profiler/panel.html.twig +++ b/pkg/enqueue-bundle/Resources/views/Profiler/panel.html.twig @@ -1,35 +1,85 @@ {% extends '@WebProfiler/Profiler/layout.html.twig' %} {% block toolbar %} + {% if collector.count > 0 %} + {% set icon %} + {{ include('@Enqueue/Icon/icon.svg') }} + + {{ collector.count }} + {% endset %} + + {% set text %} +
+ Sent messages + {{ collector.count }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} + {% endif %} {% endblock %} {% block menu %} - + + {{ include('@Enqueue/Icon/icon.svg') }} Message Queue {% endblock %} {% block panel %} + {% if collector.count > 0 %}

Sent messages

- - - - - - - - - - - {% for sentMessage in collector.sentMessages %} - - - - - - - {% endfor %} - + {% for clientName, sentMessages in collector.sentMessages %} + {% if sentMessages|length > 0 %} +

Client: {{ clientName }}

+
#TopicMessagePriority
{{ loop.index }}{{ sentMessage.topic }}
{{ collector.prettyPrintMessage(sentMessage.body)|raw }}
{{ collector.prettyPrintPriority(sentMessage.priority) }}
+ + + + + + + + + + + + {% for sentMessage in sentMessages %} + + + + + + + + {% endfor %} + -
#TopicCommandMessagePriorityTime
{{ loop.index }}{{ sentMessage.topic|default(null) }}{{ sentMessage.command|default(null) }} + {{ collector.prettyPrintPriority(sentMessage.priority) }} + + {{ sentMessage.sentAt|date('i:s.v') }} +
+ + {% endif %} + {% endfor %} + {% else %} +
+

No messages were sent.

+
+ {% endif %} {% endblock %} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/AbstractAsyncListener.php b/pkg/enqueue-bundle/Tests/Functional/App/AbstractAsyncListener.php new file mode 100644 index 000000000..acf7406e1 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/AbstractAsyncListener.php @@ -0,0 +1,48 @@ +producer = $producer; + $this->registry = $registry; + } + + /** + * @param Event|ContractEvent $event + * @param string $eventName + */ + protected function onEventInternal($event, $eventName) + { + if (false == $this->isSyncMode($eventName)) { + $transformerName = $this->registry->getTransformerNameForEvent($eventName); + + $interopMessage = $this->registry->getTransformer($transformerName)->toMessage($eventName, $event); + $message = new Message($interopMessage->getBody()); + $message->setScope(Message::SCOPE_APP); + $message->setProperty('event_name', $eventName); + $message->setProperty('transformer_name', $transformerName); + + $this->producer->sendCommand(Commands::DISPATCH_ASYNC_EVENTS, $message); + } + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/AppKernel.php b/pkg/enqueue-bundle/Tests/Functional/App/AppKernel.php new file mode 100644 index 000000000..3cafeedda --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/AppKernel.php @@ -0,0 +1,46 @@ +load(__DIR__.'/config/config-sf5.yml'); + + return; + } + + $loader->load(__DIR__.'/config/config.yml'); + } + + protected function getContainerClass(): string + { + return parent::getContainerClass().'BundleDefault'; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/AsyncListener.php b/pkg/enqueue-bundle/Tests/Functional/App/AsyncListener.php new file mode 100644 index 000000000..23ab4af79 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/AsyncListener.php @@ -0,0 +1,16 @@ +onEventInternal($event, $eventName); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/CustomAppKernel.php b/pkg/enqueue-bundle/Tests/Functional/App/CustomAppKernel.php new file mode 100644 index 000000000..81d73796e --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/CustomAppKernel.php @@ -0,0 +1,81 @@ + [ + 'client' => [ + 'prefix' => 'enqueue', + 'app_name' => '', + 'router_topic' => 'test', + 'router_queue' => 'test', + 'default_queue' => 'test', + ], + ], + ]; + + public function setEnqueueConfig(array $config): void + { + $this->enqueueConfig = array_replace_recursive($this->enqueueConfig, $config); + $this->enqueueConfig['default']['client']['app_name'] = str_replace('.', '', uniqid('app_name', true)); + $this->enqueueConfigId = md5(json_encode($this->enqueueConfig)); + + $fs = new Filesystem(); + $fs->remove(sys_get_temp_dir().'/EnqueueBundleCustom/cache/'.$this->enqueueConfigId); + $fs->mkdir(sys_get_temp_dir().'/EnqueueBundleCustom/cache/'.$this->enqueueConfigId); + } + + public function registerBundles(): iterable + { + $bundles = [ + new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), + new \Doctrine\Bundle\DoctrineBundle\DoctrineBundle(), + new \Enqueue\Bundle\EnqueueBundle(), + ]; + + return $bundles; + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/EnqueueBundleCustom/cache/'.$this->enqueueConfigId; + } + + public function getLogDir(): string + { + return sys_get_temp_dir().'/EnqueueBundleCustom/cache/logs/'.$this->enqueueConfigId; + } + + protected function getContainerClass(): string + { + return parent::getContainerClass().'Custom'.$this->enqueueConfigId; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader) + { + if (self::VERSION_ID < 60000) { + $loader->load(__DIR__.'/config/custom-config-sf5.yml'); + } else { + $loader->load(__DIR__.'/config/custom-config.yml'); + } + + $c->loadFromExtension('enqueue', $this->enqueueConfig); + } + + protected function configureRoutes(RoutingConfigurator $routes) + { + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/SqsCustomConnectionFactoryFactory.php b/pkg/enqueue-bundle/Tests/Functional/App/SqsCustomConnectionFactoryFactory.php new file mode 100644 index 000000000..6ed70cd65 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/SqsCustomConnectionFactoryFactory.php @@ -0,0 +1,30 @@ +container = $container; + } + + public function create($config): ConnectionFactory + { + if (false == isset($config['service'])) { + throw new \LogicException('The sqs client has to be set'); + } + + return new SqsConnectionFactory($this->container->get($config['service'])); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncEventTransformer.php b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncEventTransformer.php new file mode 100644 index 000000000..0a83a04b6 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncEventTransformer.php @@ -0,0 +1,51 @@ +context = $context; + } + + public function toMessage($eventName, ?Event $event = null) + { + if (Event::class === $event::class) { + return $this->context->createMessage(json_encode('')); + } + + /** @var GenericEvent $event */ + if (false == $event instanceof GenericEvent) { + throw new \LogicException('Must be GenericEvent'); + } + + return $this->context->createMessage(json_encode([ + 'subject' => $event->getSubject(), + 'arguments' => $event->getArguments(), + ])); + } + + public function toEvent($eventName, Message $message) + { + $data = JSON::decode($message->getBody()); + + if ('' === $data) { + return new Event(); + } + + return new GenericEvent($data['subject'], $data['arguments']); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncListener.php b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncListener.php new file mode 100644 index 000000000..fd0ec1d91 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncListener.php @@ -0,0 +1,15 @@ +calls[] = func_get_args(); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncSubscriber.php b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncSubscriber.php new file mode 100644 index 000000000..cd3beb45b --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestAsyncSubscriber.php @@ -0,0 +1,21 @@ +calls[] = func_get_args(); + } + + public static function getSubscribedEvents() + { + return ['test_async_subscriber' => 'onEvent']; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestCommandSubscriberProcessor.php b/pkg/enqueue-bundle/Tests/Functional/App/TestCommandSubscriberProcessor.php new file mode 100644 index 000000000..480992ff1 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestCommandSubscriberProcessor.php @@ -0,0 +1,28 @@ +calls[] = $message; + + return Result::reply( + $context->createMessage($message->getBody().'Reply') + ); + } + + public static function getSubscribedCommand() + { + return 'theCommand'; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestExclusiveCommandSubscriberProcessor.php b/pkg/enqueue-bundle/Tests/Functional/App/TestExclusiveCommandSubscriberProcessor.php new file mode 100644 index 000000000..999ad1f24 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestExclusiveCommandSubscriberProcessor.php @@ -0,0 +1,32 @@ +calls[] = $message; + + return Result::ACK; + } + + public static function getSubscribedCommand() + { + return [ + 'command' => 'theExclusiveCommandName', + 'processor' => 'theExclusiveCommandName', + 'queue' => 'the_exclusive_command_queue', + 'prefix_queue' => true, + 'exclusive' => true, + ]; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/TestTopicSubscriberProcessor.php b/pkg/enqueue-bundle/Tests/Functional/App/TestTopicSubscriberProcessor.php new file mode 100644 index 000000000..2b9f16ead --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/TestTopicSubscriberProcessor.php @@ -0,0 +1,28 @@ +calls[] = $message; + + return Result::reply( + $context->createMessage($message->getBody().'Reply') + ); + } + + public static function getSubscribedTopics() + { + return 'theTopic'; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/App/config/config-sf5.yml b/pkg/enqueue-bundle/Tests/Functional/App/config/config-sf5.yml new file mode 100644 index 000000000..e202bb86f --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/config/config-sf5.yml @@ -0,0 +1,129 @@ +parameters: + locale: 'en' + secret: 'ThisTokenIsNotSoSecretChangeIt' + + +framework: + #esi: ~ + #translator: { fallback: "%locale%" } + test: ~ + assets: false + session: + # option incompatible with Symfony 6 + storage_id: session.storage.mock_file + secret: '%secret%' + router: { resource: '%kernel.project_dir%/config/routing.yml' } + default_locale: '%locale%' + +doctrine: + dbal: + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql + charset: UTF8 + +enqueue: + default: + transport: 'null:' + client: + traceable_producer: true + job: true + async_events: true + async_commands: + enabled: true + timeout: 60 + command_name: ~ + queue_name: ~ + +services: + test_enqueue.client.default.traceable_producer: + alias: 'enqueue.client.default.traceable_producer' + public: true + + test_enqueue.transport.default.queue_consumer: + alias: 'enqueue.transport.default.queue_consumer' + public: true + + test_enqueue.client.default.queue_consumer: + alias: 'enqueue.client.default.queue_consumer' + public: true + + test_enqueue.transport.default.rpc_client: + alias: 'enqueue.transport.default.rpc_client' + public: true + + test_enqueue.client.default.producer: + alias: 'enqueue.client.default.producer' + public: true + + test_enqueue.client.default.spool_producer: + alias: 'enqueue.client.default.spool_producer' + public: true + + test_Enqueue\Client\ProducerInterface: + alias: 'Enqueue\Client\ProducerInterface' + public: true + + test_enqueue.client.default.driver: + alias: 'enqueue.client.default.driver' + public: true + + test_enqueue.transport.default.context: + alias: 'enqueue.transport.default.context' + public: true + + test_enqueue.client.consume_command: + alias: 'enqueue.client.consume_command' + public: true + + test.enqueue.client.routes_command: + alias: 'enqueue.client.routes_command' + public: true + + test.enqueue.events.async_processor: + alias: 'enqueue.events.async_processor' + public: true + + test_async_listener: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncListener' + public: true + tags: + - { name: 'kernel.event_listener', async: true, event: 'test_async', method: 'onEvent', dispatcher: 'enqueue.events.event_dispatcher' } + + test_command_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestCommandSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test_topic_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestTopicSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.topic_subscriber', client: 'default' } + + test_exclusive_command_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestExclusiveCommandSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test_async_subscriber: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncSubscriber' + public: true + tags: + - { name: 'kernel.event_subscriber', async: true, dispatcher: 'enqueue.events.event_dispatcher' } + + test_async_event_transformer: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncEventTransformer' + public: true + arguments: + - '@enqueue.transport.default.context' + tags: + - {name: 'enqueue.event_transformer', eventName: 'test_async', transformerName: 'test_async' } + - {name: 'enqueue.event_transformer', eventName: 'test_async_subscriber', transformerName: 'test_async' } + + # overwrite async listener with one based on client producer. so we can use traceable producer. + enqueue.events.async_listener: + class: 'Enqueue\Bundle\Tests\Functional\App\AsyncListener' + public: true + arguments: ['@enqueue.client.default.producer', '@enqueue.events.registry'] diff --git a/pkg/enqueue-bundle/Tests/Functional/App/config/config.yml b/pkg/enqueue-bundle/Tests/Functional/App/config/config.yml new file mode 100644 index 000000000..d3ca2a37f --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/config/config.yml @@ -0,0 +1,128 @@ +parameters: + locale: 'en' + secret: 'ThisTokenIsNotSoSecretChangeIt' + + +framework: + #esi: ~ + #translator: { fallback: "%locale%" } + test: ~ + assets: false + session: + storage_factory_id: session.storage.factory.mock_file + secret: '%secret%' + router: { resource: '%kernel.project_dir%/config/routing.yml' } + default_locale: '%locale%' + +doctrine: + dbal: + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql + charset: UTF8 + +enqueue: + default: + transport: 'null:' + client: + traceable_producer: true + job: true + async_events: true + async_commands: + enabled: true + timeout: 60 + command_name: ~ + queue_name: ~ + +services: + test_enqueue.client.default.traceable_producer: + alias: 'enqueue.client.default.traceable_producer' + public: true + + test_enqueue.transport.default.queue_consumer: + alias: 'enqueue.transport.default.queue_consumer' + public: true + + test_enqueue.client.default.queue_consumer: + alias: 'enqueue.client.default.queue_consumer' + public: true + + test_enqueue.transport.default.rpc_client: + alias: 'enqueue.transport.default.rpc_client' + public: true + + test_enqueue.client.default.producer: + alias: 'enqueue.client.default.producer' + public: true + + test_enqueue.client.default.spool_producer: + alias: 'enqueue.client.default.spool_producer' + public: true + + test_Enqueue\Client\ProducerInterface: + alias: 'Enqueue\Client\ProducerInterface' + public: true + + test_enqueue.client.default.driver: + alias: 'enqueue.client.default.driver' + public: true + + test_enqueue.transport.default.context: + alias: 'enqueue.transport.default.context' + public: true + + test_enqueue.client.consume_command: + alias: 'enqueue.client.consume_command' + public: true + + test.enqueue.client.routes_command: + alias: 'enqueue.client.routes_command' + public: true + + test.enqueue.events.async_processor: + alias: 'enqueue.events.async_processor' + public: true + + test_async_listener: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncListener' + public: true + tags: + - { name: 'kernel.event_listener', async: true, event: 'test_async', method: 'onEvent', dispatcher: 'enqueue.events.event_dispatcher' } + + test_command_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestCommandSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test_topic_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestTopicSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.topic_subscriber', client: 'default' } + + test_exclusive_command_subscriber_processor: + class: 'Enqueue\Bundle\Tests\Functional\App\TestExclusiveCommandSubscriberProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test_async_subscriber: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncSubscriber' + public: true + tags: + - { name: 'kernel.event_subscriber', async: true, dispatcher: 'enqueue.events.event_dispatcher' } + + test_async_event_transformer: + class: 'Enqueue\Bundle\Tests\Functional\App\TestAsyncEventTransformer' + public: true + arguments: + - '@enqueue.transport.default.context' + tags: + - {name: 'enqueue.event_transformer', eventName: 'test_async', transformerName: 'test_async' } + - {name: 'enqueue.event_transformer', eventName: 'test_async_subscriber', transformerName: 'test_async' } + + # overwrite async listener with one based on client producer. so we can use traceable producer. + enqueue.events.async_listener: + class: 'Enqueue\Bundle\Tests\Functional\App\AsyncListener' + public: true + arguments: ['@enqueue.client.default.producer', '@enqueue.events.registry'] diff --git a/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config-sf5.yml b/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config-sf5.yml new file mode 100644 index 000000000..35192652e --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config-sf5.yml @@ -0,0 +1,85 @@ +parameters: + locale: 'en' + secret: 'ThisTokenIsNotSoSecretChangeIt' + +framework: + #esi: ~ + #translator: { fallback: "%locale%" } + test: ~ + assets: false + session: + # the only option incompatible with Symfony 6 + storage_id: session.storage.mock_file + secret: '%secret%' + router: { resource: '%kernel.project_dir%/config/routing.yml' } + default_locale: '%locale%' + +doctrine: + dbal: + connections: + custom: + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql + charset: UTF8 + +services: + test_enqueue.client.default.driver: + alias: 'enqueue.client.default.driver' + public: true + + test_enqueue.client.default.producer: + alias: 'enqueue.client.default.producer' + public: true + + test_enqueue.client.default.lazy_producer: + alias: 'enqueue.client.default.lazy_producer' + public: true + + test_enqueue.transport.default.context: + alias: 'enqueue.transport.default.context' + public: true + + test_enqueue.transport.consume_command: + alias: 'enqueue.transport.consume_command' + public: true + + test_enqueue.client.consume_command: + alias: 'enqueue.client.consume_command' + public: true + + test_enqueue.client.produce_command: + alias: 'enqueue.client.produce_command' + public: true + + test_enqueue.client.setup_broker_command: + alias: 'enqueue.client.setup_broker_command' + public: true + + test.message.processor: + class: 'Enqueue\Bundle\Tests\Functional\TestProcessor' + public: true + tags: + - { name: 'enqueue.topic_subscriber', client: 'default' } + - { name: 'enqueue.transport.processor', transport: 'default' } + + test.message.command_processor: + class: 'Enqueue\Bundle\Tests\Functional\TestCommandProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test.sqs_client: + public: true + class: 'Aws\Sqs\SqsClient' + arguments: + - + endpoint: '%env(AWS_SQS_ENDPOINT)%' + region: '%env(AWS_SQS_REGION)%' + version: '%env(AWS_SQS_VERSION)%' + credentials: + key: '%env(AWS_SQS_KEY)%' + secret: '%env(AWS_SQS_SECRET)%' + + test.sqs_custom_connection_factory_factory: + class: 'Enqueue\Bundle\Tests\Functional\App\SqsCustomConnectionFactoryFactory' + arguments: ['@service_container'] diff --git a/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config.yml b/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config.yml new file mode 100644 index 000000000..d02f3002d --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/config/custom-config.yml @@ -0,0 +1,84 @@ +parameters: + locale: 'en' + secret: 'ThisTokenIsNotSoSecretChangeIt' + +framework: + #esi: ~ + #translator: { fallback: "%locale%" } + test: ~ + assets: false + session: + storage_factory_id: session.storage.factory.mock_file + secret: '%secret%' + router: { resource: '%kernel.project_dir%/config/routing.yml' } + default_locale: '%locale%' + +doctrine: + dbal: + connections: + custom: + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql + charset: UTF8 + +services: + test_enqueue.client.default.driver: + alias: 'enqueue.client.default.driver' + public: true + + test_enqueue.client.default.producer: + alias: 'enqueue.client.default.producer' + public: true + + test_enqueue.client.default.lazy_producer: + alias: 'enqueue.client.default.lazy_producer' + public: true + + test_enqueue.transport.default.context: + alias: 'enqueue.transport.default.context' + public: true + + test_enqueue.transport.consume_command: + alias: 'enqueue.transport.consume_command' + public: true + + test_enqueue.client.consume_command: + alias: 'enqueue.client.consume_command' + public: true + + test_enqueue.client.produce_command: + alias: 'enqueue.client.produce_command' + public: true + + test_enqueue.client.setup_broker_command: + alias: 'enqueue.client.setup_broker_command' + public: true + + test.message.processor: + class: 'Enqueue\Bundle\Tests\Functional\TestProcessor' + public: true + tags: + - { name: 'enqueue.topic_subscriber', client: 'default' } + - { name: 'enqueue.transport.processor', transport: 'default' } + + test.message.command_processor: + class: 'Enqueue\Bundle\Tests\Functional\TestCommandProcessor' + public: true + tags: + - { name: 'enqueue.command_subscriber', client: 'default' } + + test.sqs_client: + public: true + class: 'Aws\Sqs\SqsClient' + arguments: + - + endpoint: '%env(AWS_SQS_ENDPOINT)%' + region: '%env(AWS_SQS_REGION)%' + version: '%env(AWS_SQS_VERSION)%' + credentials: + key: '%env(AWS_SQS_KEY)%' + secret: '%env(AWS_SQS_SECRET)%' + + test.sqs_custom_connection_factory_factory: + class: 'Enqueue\Bundle\Tests\Functional\App\SqsCustomConnectionFactoryFactory' + arguments: ['@service_container'] diff --git a/pkg/enqueue-bundle/Tests/Functional/app/config/routing.yml b/pkg/enqueue-bundle/Tests/Functional/App/config/routing.yml similarity index 100% rename from pkg/enqueue-bundle/Tests/Functional/app/config/routing.yml rename to pkg/enqueue-bundle/Tests/Functional/App/config/routing.yml diff --git a/pkg/enqueue-bundle/Tests/Functional/App/console.php b/pkg/enqueue-bundle/Tests/Functional/App/console.php new file mode 100644 index 000000000..2f518c78f --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/App/console.php @@ -0,0 +1,12 @@ +#!/usr/bin/env php +run(new ArgvInput()); diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/ConsumeMessagesCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/ConsumeMessagesCommandTest.php deleted file mode 100644 index 8cad85ec6..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/Client/ConsumeMessagesCommandTest.php +++ /dev/null @@ -1,19 +0,0 @@ -container->get('enqueue.client.consume_messages_command'); - - $this->assertInstanceOf(ConsumeMessagesCommand::class, $command); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/DriverTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/DriverTest.php deleted file mode 100644 index 2900bcdd9..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/Client/DriverTest.php +++ /dev/null @@ -1,19 +0,0 @@ -container->get('enqueue.client.driver'); - - $this->assertInstanceOf(DriverInterface::class, $driver); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/MessageProducerTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/MessageProducerTest.php deleted file mode 100644 index ea9eb9ce9..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/Client/MessageProducerTest.php +++ /dev/null @@ -1,27 +0,0 @@ -container->get('enqueue.client.message_producer'); - - $this->assertInstanceOf(MessageProducerInterface::class, $messageProducer); - } - - public function testCouldBeGetFromContainerAsShortenAlias() - { - $messageProducer = $this->container->get('enqueue.client.message_producer'); - $aliasMessageProducer = $this->container->get('enqueue.message_producer'); - - $this->assertSame($messageProducer, $aliasMessageProducer); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/ProduceMessageCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/ProduceMessageCommandTest.php deleted file mode 100644 index b003805cf..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/Client/ProduceMessageCommandTest.php +++ /dev/null @@ -1,19 +0,0 @@ -container->get('enqueue.client.produce_message_command'); - - $this->assertInstanceOf(ProduceMessageCommand::class, $command); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/ProducerTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/ProducerTest.php new file mode 100644 index 000000000..29a96aa7d --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/Client/ProducerTest.php @@ -0,0 +1,125 @@ +get('test_'.ProducerInterface::class); + + $this->assertInstanceOf(ProducerInterface::class, $producer); + } + + public function testCouldBeGetFromContainerByServiceId() + { + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $this->assertInstanceOf(ProducerInterface::class, $producer); + } + + public function testShouldSendEvent() + { + /** @var ProducerInterface $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $producer->sendEvent('theTopic', 'theMessage'); + + $traces = $this->getTraceableProducer()->getTopicTraces('theTopic'); + + $this->assertCount(1, $traces); + $this->assertEquals('theMessage', $traces[0]['body']); + } + + public function testShouldSendCommandWithoutNeedForReply() + { + /** @var ProducerInterface $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $result = $producer->sendCommand('theCommand', 'theMessage', false); + + $this->assertNull($result); + + $traces = $this->getTraceableProducer()->getCommandTraces('theCommand'); + + $this->assertCount(1, $traces); + $this->assertEquals('theMessage', $traces[0]['body']); + } + + public function testShouldSendMessageInstanceAsCommandWithoutNeedForReply() + { + /** @var ProducerInterface $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $message = new Message('theMessage'); + + $result = $producer->sendCommand('theCommand', $message, false); + + $this->assertNull($result); + + $traces = $this->getTraceableProducer()->getCommandTraces('theCommand'); + + $this->assertCount(1, $traces); + $this->assertEquals('theMessage', $traces[0]['body']); + $this->assertEquals([ + 'enqueue.processor' => 'test_command_subscriber_processor', + 'enqueue.command' => 'theCommand', + ], $traces[0]['properties']); + } + + public function testShouldSendExclusiveCommandWithNeedForReply() + { + /** @var ProducerInterface $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $message = new Message('theMessage'); + + $result = $producer->sendCommand('theExclusiveCommandName', $message, false); + + $this->assertNull($result); + + $traces = $this->getTraceableProducer()->getCommandTraces('theExclusiveCommandName'); + + $this->assertCount(1, $traces); + $this->assertEquals('theMessage', $traces[0]['body']); + $this->assertEquals([ + 'enqueue.processor' => 'theExclusiveCommandName', + 'enqueue.command' => 'theExclusiveCommandName', + ], $traces[0]['properties']); + } + + public function testShouldSendMessageInstanceCommandWithNeedForReply() + { + /** @var ProducerInterface $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $message = new Message('theMessage'); + + $result = $producer->sendCommand('theCommand', $message, true); + + $this->assertInstanceOf(Promise::class, $result); + + $traces = $this->getTraceableProducer()->getCommandTraces('theCommand'); + + $this->assertCount(1, $traces); + $this->assertEquals('theMessage', $traces[0]['body']); + $this->assertEquals([ + 'enqueue.processor' => 'test_command_subscriber_processor', + 'enqueue.command' => 'theCommand', + ], $traces[0]['properties']); + } + + private function getTraceableProducer(): TraceableProducer + { + return static::$container->get('test_enqueue.client.default.traceable_producer'); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/Client/SpoolProducerTest.php b/pkg/enqueue-bundle/Tests/Functional/Client/SpoolProducerTest.php new file mode 100644 index 000000000..0bba43327 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/Client/SpoolProducerTest.php @@ -0,0 +1,19 @@ +get('test_enqueue.client.default.spool_producer'); + + $this->assertInstanceOf(SpoolProducer::class, $producer); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/ConsumeMessagesCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/ConsumeMessagesCommandTest.php deleted file mode 100644 index 4e3825f21..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/ConsumeMessagesCommandTest.php +++ /dev/null @@ -1,105 +0,0 @@ -removeExchange('amqp.test'); - $this->removeQueue('amqp.app.test'); - - $driver = $this->container->get('enqueue.client.driver'); - $driver->setupBroker(); - } - - public function testCouldBeGetFromContainerAsService() - { - $command = $this->container->get('enqueue.client.consume_messages_command'); - - $this->assertInstanceOf(ConsumeMessagesCommand::class, $command); - } - - public function testClientConsumeMessagesCommandShouldConsumeMessage() - { - $command = $this->container->get('enqueue.client.consume_messages_command'); - $processor = $this->container->get('test.message.processor'); - - $this->getMessageProducer()->send(TestProcessor::TOPIC, 'test message body'); - - $tester = new CommandTester($command); - $tester->execute([ - '--message-limit' => 2, - '--time-limit' => 'now +10 seconds', - ]); - - $this->assertInstanceOf(AmqpMessage::class, $processor->message); - $this->assertEquals('test message body', $processor->message->getBody()); - } - - public function testClientConsumeMessagesFromExplicitlySetQueue() - { - $command = $this->container->get('enqueue.client.consume_messages_command'); - $processor = $this->container->get('test.message.processor'); - - $this->getMessageProducer()->send(TestProcessor::TOPIC, 'test message body'); - - $tester = new CommandTester($command); - $tester->execute([ - '--message-limit' => 2, - '--time-limit' => 'now +10 seconds', - 'client-queue-names' => ['test'], - ]); - - $this->assertInstanceOf(AmqpMessage::class, $processor->message); - $this->assertEquals('test message body', $processor->message->getBody()); - } - - public function testTransportConsumeMessagesCommandShouldConsumeMessage() - { - $command = $this->container->get('enqueue.command.consume_messages'); - $command->setContainer($this->container); - $processor = $this->container->get('test.message.processor'); - - $this->getMessageProducer()->send(TestProcessor::TOPIC, 'test message body'); - - $tester = new CommandTester($command); - $tester->execute([ - '--message-limit' => 1, - '--time-limit' => '+10sec', - 'queue' => 'amqp.app.test', - 'processor-service' => 'test.message.processor', - ]); - - $this->assertInstanceOf(AmqpMessage::class, $processor->message); - $this->assertEquals('test message body', $processor->message->getBody()); - } - - /** - * @return string - */ - public static function getKernelClass() - { - include_once __DIR__.'/app/AmqpAppKernel.php'; - - return AmqpAppKernel::class; - } - - private function getMessageProducer() - { - return $this->container->get('enqueue.client.message_producer'); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/ContextTest.php b/pkg/enqueue-bundle/Tests/Functional/ContextTest.php index f11a9b17a..87e2d95a0 100644 --- a/pkg/enqueue-bundle/Tests/Functional/ContextTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/ContextTest.php @@ -2,7 +2,7 @@ namespace Enqueue\Bundle\Tests\Functional; -use Enqueue\Psr\Context; +use Interop\Queue\Context; /** * @group functional @@ -11,7 +11,7 @@ class ContextTest extends WebTestCase { public function testCouldBeGetFromContainerAsService() { - $connection = $this->container->get('enqueue.transport.context'); + $connection = static::$container->get('test_enqueue.transport.default.context'); $this->assertInstanceOf(Context::class, $connection); } diff --git a/pkg/enqueue-bundle/Tests/Functional/Events/AsyncListenerTest.php b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncListenerTest.php new file mode 100644 index 000000000..7fb6fdd86 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncListenerTest.php @@ -0,0 +1,111 @@ +get('enqueue.events.async_listener'); + + $asyncListener->resetSyncMode(); + static::$container->get('test_async_subscriber')->calls = []; + static::$container->get('test_async_listener')->calls = []; + } + + public function testShouldNotCallRealListenerIfMarkedAsAsync() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $this->dispatch($dispatcher, new GenericEvent('aSubject'), 'test_async'); + + /** @var TestAsyncListener $listener */ + $listener = static::$container->get('test_async_listener'); + + $this->assertEmpty($listener->calls); + } + + public function testShouldSendMessageToExpectedCommandInsteadOfCallingRealListener() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $event = new GenericEvent('theSubject', ['fooArg' => 'fooVal']); + + $this->dispatch($dispatcher, $event, 'test_async'); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(1, $traces); + + $this->assertEquals(Commands::DISPATCH_ASYNC_EVENTS, $traces[0]['command']); + $this->assertEquals('{"subject":"theSubject","arguments":{"fooArg":"fooVal"}}', $traces[0]['body']); + } + + public function testShouldSendMessageForEveryDispatchCall() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async'); + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async'); + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async'); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(3, $traces); + } + + public function testShouldSendMessageIfDispatchedFromInsideListener() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $eventName = 'an_event_'.uniqid(); + $dispatcher->addListener($eventName, function ($event, $eventName, EventDispatcherInterface $dispatcher) { + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async'); + }); + + $this->dispatch($dispatcher, new GenericEvent(), $eventName); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(1, $traces); + } + + private function dispatch(EventDispatcherInterface $dispatcher, $event, $eventName): void + { + if (!class_exists(Event::class)) { + // Symfony 5 + $dispatcher->dispatch($event, $eventName); + } else { + // Symfony < 5 + $dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/Events/AsyncProcessorTest.php b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncProcessorTest.php new file mode 100644 index 000000000..d85567509 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncProcessorTest.php @@ -0,0 +1,123 @@ +get('enqueue.events.async_listener'); + + $asyncListener->resetSyncMode(); + static::$container->get('test_async_subscriber')->calls = []; + static::$container->get('test_async_listener')->calls = []; + } + + public function testCouldBeGetFromContainerAsService() + { + /** @var AsyncProcessor $processor */ + $processor = static::$container->get('test.enqueue.events.async_processor'); + + $this->assertInstanceOf(AsyncProcessor::class, $processor); + } + + public function testShouldRejectIfMessageDoesNotContainEventNameProperty() + { + /** @var AsyncProcessor $processor */ + $processor = static::$container->get('test.enqueue.events.async_processor'); + + $message = new NullMessage(); + + $this->assertEquals(Processor::REJECT, $processor->process($message, new NullContext())); + } + + public function testShouldRejectIfMessageDoesNotContainTransformerNameProperty() + { + /** @var AsyncProcessor $processor */ + $processor = static::$container->get('test.enqueue.events.async_processor'); + + $message = new NullMessage(); + $message->setProperty('event_name', 'anEventName'); + + $this->assertEquals(Processor::REJECT, $processor->process($message, new NullContext())); + } + + public function testShouldCallRealListener() + { + /** @var AsyncProcessor $processor */ + $processor = static::$container->get('test.enqueue.events.async_processor'); + + $message = new NullMessage(); + $message->setProperty('event_name', 'test_async'); + $message->setProperty('transformer_name', 'test_async'); + $message->setBody(JSON::encode([ + 'subject' => 'theSubject', + 'arguments' => ['fooArg' => 'fooVal'], + ])); + + $this->assertEquals(Processor::ACK, $processor->process($message, new NullContext())); + + /** @var TestAsyncListener $listener */ + $listener = static::$container->get('test_async_listener'); + + $this->assertNotEmpty($listener->calls); + + $this->assertInstanceOf(GenericEvent::class, $listener->calls[0][0]); + $this->assertEquals('theSubject', $listener->calls[0][0]->getSubject()); + $this->assertEquals(['fooArg' => 'fooVal'], $listener->calls[0][0]->getArguments()); + $this->assertEquals('test_async', $listener->calls[0][1]); + + $this->assertSame( + static::$container->get('enqueue.events.event_dispatcher'), + $listener->calls[0][2] + ); + } + + public function testShouldCallRealSubscriber() + { + /** @var AsyncProcessor $processor */ + $processor = static::$container->get('test.enqueue.events.async_processor'); + + $message = new NullMessage(); + $message->setProperty('event_name', 'test_async_subscriber'); + $message->setProperty('transformer_name', 'test_async'); + $message->setBody(JSON::encode([ + 'subject' => 'theSubject', + 'arguments' => ['fooArg' => 'fooVal'], + ])); + + $this->assertEquals(Processor::ACK, $processor->process($message, new NullContext())); + + /** @var TestAsyncSubscriber $subscriber */ + $subscriber = static::$container->get('test_async_subscriber'); + + $this->assertNotEmpty($subscriber->calls); + + $this->assertInstanceOf(GenericEvent::class, $subscriber->calls[0][0]); + $this->assertEquals('theSubject', $subscriber->calls[0][0]->getSubject()); + $this->assertEquals(['fooArg' => 'fooVal'], $subscriber->calls[0][0]->getArguments()); + $this->assertEquals('test_async_subscriber', $subscriber->calls[0][1]); + + $this->assertSame( + static::$container->get('enqueue.events.event_dispatcher'), + $subscriber->calls[0][2] + ); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/Events/AsyncSubscriberTest.php b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncSubscriberTest.php new file mode 100644 index 000000000..4b145524a --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/Events/AsyncSubscriberTest.php @@ -0,0 +1,111 @@ +get('enqueue.events.async_listener'); + + $asyncListener->resetSyncMode(); + static::$container->get('test_async_subscriber')->calls = []; + static::$container->get('test_async_listener')->calls = []; + } + + public function testShouldNotCallRealSubscriberIfMarkedAsAsync() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $this->dispatch($dispatcher, new GenericEvent('aSubject'), 'test_async_subscriber'); + + /** @var TestAsyncSubscriber $listener */ + $listener = static::$container->get('test_async_subscriber'); + + $this->assertEmpty($listener->calls); + } + + public function testShouldSendMessageToExpectedTopicInsteadOfCallingRealSubscriber() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $event = new GenericEvent('theSubject', ['fooArg' => 'fooVal']); + + $this->dispatch($dispatcher, $event, 'test_async_subscriber'); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(1, $traces); + + $this->assertEquals(Commands::DISPATCH_ASYNC_EVENTS, $traces[0]['command']); + $this->assertEquals('{"subject":"theSubject","arguments":{"fooArg":"fooVal"}}', $traces[0]['body']); + } + + public function testShouldSendMessageForEveryDispatchCall() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async_subscriber'); + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async_subscriber'); + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async_subscriber'); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(3, $traces); + } + + public function testShouldSendMessageIfDispatchedFromInsideListener() + { + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = static::$container->get('event_dispatcher'); + + $eventName = 'anEvent'.uniqid(); + $dispatcher->addListener($eventName, function ($event, $eventName, EventDispatcherInterface $dispatcher) { + $this->dispatch($dispatcher, new GenericEvent('theSubject', ['fooArg' => 'fooVal']), 'test_async_subscriber'); + }); + + $this->dispatch($dispatcher, new GenericEvent(), $eventName); + + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.producer'); + + $traces = $producer->getCommandTraces(Commands::DISPATCH_ASYNC_EVENTS); + + $this->assertCount(1, $traces); + } + + private function dispatch(EventDispatcherInterface $dispatcher, $event, $eventName): void + { + if (!class_exists(Event::class)) { + // Symfony 5 + $dispatcher->dispatch($event, $eventName); + } else { + // Symfony < 5 + $dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/Job/CalculateRootJobStatusProcessorTest.php b/pkg/enqueue-bundle/Tests/Functional/Job/CalculateRootJobStatusProcessorTest.php index cd8d92435..01346ad8c 100644 --- a/pkg/enqueue-bundle/Tests/Functional/Job/CalculateRootJobStatusProcessorTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/Job/CalculateRootJobStatusProcessorTest.php @@ -12,7 +12,7 @@ class CalculateRootJobStatusProcessorTest extends WebTestCase { public function testCouldBeConstructedByContainer() { - $instance = $this->container->get('enqueue.job.calculate_root_job_status_processor'); + $instance = static::$container->get(CalculateRootJobStatusProcessor::class); $this->assertInstanceOf(CalculateRootJobStatusProcessor::class, $instance); } diff --git a/pkg/enqueue-bundle/Tests/Functional/Job/DependentJobServiceTest.php b/pkg/enqueue-bundle/Tests/Functional/Job/DependentJobServiceTest.php index ca0fdccd4..1aec06410 100644 --- a/pkg/enqueue-bundle/Tests/Functional/Job/DependentJobServiceTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/Job/DependentJobServiceTest.php @@ -12,7 +12,7 @@ class DependentJobServiceTest extends WebTestCase { public function testCouldBeConstructedByContainer() { - $instance = $this->container->get('enqueue.job.dependent_job_service'); + $instance = static::$container->get(DependentJobService::class); $this->assertInstanceOf(DependentJobService::class, $instance); } diff --git a/pkg/enqueue-bundle/Tests/Functional/Job/JobRunnerTest.php b/pkg/enqueue-bundle/Tests/Functional/Job/JobRunnerTest.php index e63fc4f20..4aa647f77 100644 --- a/pkg/enqueue-bundle/Tests/Functional/Job/JobRunnerTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/Job/JobRunnerTest.php @@ -12,7 +12,7 @@ class JobRunnerTest extends WebTestCase { public function testCouldBeConstructedByContainer() { - $instance = $this->container->get('enqueue.job.runner'); + $instance = static::$container->get(JobRunner::class); $this->assertInstanceOf(JobRunner::class, $instance); } diff --git a/pkg/enqueue-bundle/Tests/Functional/Job/JobStorageTest.php b/pkg/enqueue-bundle/Tests/Functional/Job/JobStorageTest.php index 93cff10a2..650326ad8 100644 --- a/pkg/enqueue-bundle/Tests/Functional/Job/JobStorageTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/Job/JobStorageTest.php @@ -3,7 +3,7 @@ namespace Enqueue\Bundle\Tests\Functional\Job; use Enqueue\Bundle\Tests\Functional\WebTestCase; -use Enqueue\JobQueue\JobStorage; +use Enqueue\JobQueue\Doctrine\JobStorage; /** * @group functional @@ -12,7 +12,7 @@ class JobStorageTest extends WebTestCase { public function testCouldGetJobStorageAsServiceFromContainer() { - $instance = $this->container->get('enqueue.job.storage'); + $instance = static::$container->get(JobStorage::class); $this->assertInstanceOf(JobStorage::class, $instance); } diff --git a/pkg/enqueue-bundle/Tests/Functional/LazyProducerTest.php b/pkg/enqueue-bundle/Tests/Functional/LazyProducerTest.php new file mode 100644 index 000000000..18375aef3 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/LazyProducerTest.php @@ -0,0 +1,64 @@ +customSetUp([ + 'default' => [ + 'transport' => [ + 'dsn' => 'invalidDSN', + ], + ], + ]); + + /** @var LazyProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.lazy_producer'); + $this->assertInstanceOf(LazyProducer::class, $producer); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + $producer->sendEvent('foo', 'foo'); + } + + public static function getKernelClass(): string + { + include_once __DIR__.'/App/CustomAppKernel.php'; + + return CustomAppKernel::class; + } + + protected function customSetUp(array $enqueueConfig) + { + static::$class = null; + + static::$client = static::createClient(['enqueue_config' => $enqueueConfig]); + static::$client->getKernel()->boot(); + static::$kernel = static::$client->getKernel(); + static::$container = static::$kernel->getContainer(); + } + + protected static function createKernel(array $options = []): CustomAppKernel + { + /** @var CustomAppKernel $kernel */ + $kernel = parent::createKernel($options); + + $kernel->setEnqueueConfig(isset($options['enqueue_config']) ? $options['enqueue_config'] : []); + + return $kernel; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/QueueConsumerTest.php b/pkg/enqueue-bundle/Tests/Functional/QueueConsumerTest.php index 8ad6c46ac..e05d1532d 100644 --- a/pkg/enqueue-bundle/Tests/Functional/QueueConsumerTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/QueueConsumerTest.php @@ -11,8 +11,10 @@ class QueueConsumerTest extends WebTestCase { public function testCouldBeGetFromContainerAsService() { - $queueConsumer = $this->container->get('enqueue.consumption.queue_consumer'); + $queueConsumer = static::$container->get('test_enqueue.client.default.queue_consumer'); + $this->assertInstanceOf(QueueConsumer::class, $queueConsumer); + $queueConsumer = static::$container->get('test_enqueue.transport.default.queue_consumer'); $this->assertInstanceOf(QueueConsumer::class, $queueConsumer); } } diff --git a/pkg/enqueue-bundle/Tests/Functional/QueueRegistryTest.php b/pkg/enqueue-bundle/Tests/Functional/QueueRegistryTest.php deleted file mode 100644 index fdd35549c..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/QueueRegistryTest.php +++ /dev/null @@ -1,18 +0,0 @@ -container->get('enqueue.client.meta.queue_meta_registry'); - - $this->assertInstanceOf(QueueMetaRegistry::class, $connection); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/QueuesCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/QueuesCommandTest.php deleted file mode 100644 index 76ff88a83..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/QueuesCommandTest.php +++ /dev/null @@ -1,33 +0,0 @@ -container->get('enqueue.client.meta.queues_command'); - - $this->assertInstanceOf(QueuesCommand::class, $command); - } - - public function testShouldDisplayRegisteredDestionations() - { - $command = $this->container->get('enqueue.client.meta.queues_command'); - - $tester = new CommandTester($command); - $tester->execute([]); - - $display = $tester->getDisplay(); - - $this->assertContains(' default ', $display); - $this->assertContains('enqueue.app.default', $display); - $this->assertContains('enqueue.client.router_processor', $display); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/RoutesCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/RoutesCommandTest.php new file mode 100644 index 000000000..66833b1ce --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/RoutesCommandTest.php @@ -0,0 +1,51 @@ +get('test.enqueue.client.routes_command'); + + $this->assertInstanceOf(RoutesCommand::class, $command); + } + + public function testShouldDisplayRegisteredTopics() + { + /** @var RoutesCommand $command */ + $command = static::$container->get('test.enqueue.client.routes_command'); + + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('| topic', $tester->getDisplay()); + $this->assertStringContainsString('| theTopic', $tester->getDisplay()); + $this->assertStringContainsString('| default (prefixed)', $tester->getDisplay()); + $this->assertStringContainsString('| test_topic_subscriber_processor', $tester->getDisplay()); + $this->assertStringContainsString('| (hidden)', $tester->getDisplay()); + } + + public function testShouldDisplayCommands() + { + /** @var RoutesCommand $command */ + $command = static::$container->get('test.enqueue.client.routes_command'); + + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('| command', $tester->getDisplay()); + $this->assertStringContainsString('| theCommand', $tester->getDisplay()); + $this->assertStringContainsString('| test_command_subscriber_processor', $tester->getDisplay()); + $this->assertStringContainsString('| default (prefixed)', $tester->getDisplay()); + $this->assertStringContainsString('| (hidden)', $tester->getDisplay()); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/RpcClientTest.php b/pkg/enqueue-bundle/Tests/Functional/RpcClientTest.php new file mode 100644 index 000000000..3d99bcc72 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/RpcClientTest.php @@ -0,0 +1,18 @@ +get('test_enqueue.transport.default.rpc_client'); + + $this->assertInstanceOf(RpcClient::class, $rpcClient); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/TestCommandProcessor.php b/pkg/enqueue-bundle/Tests/Functional/TestCommandProcessor.php new file mode 100644 index 000000000..dfc2bb864 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/TestCommandProcessor.php @@ -0,0 +1,30 @@ +message = $message; + + return self::ACK; + } + + public static function getSubscribedCommand() + { + return self::COMMAND; + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/TestProcessor.php b/pkg/enqueue-bundle/Tests/Functional/TestProcessor.php index 0f977dd80..9b54bdf2d 100644 --- a/pkg/enqueue-bundle/Tests/Functional/TestProcessor.php +++ b/pkg/enqueue-bundle/Tests/Functional/TestProcessor.php @@ -3,13 +3,13 @@ namespace Enqueue\Bundle\Tests\Functional; use Enqueue\Client\TopicSubscriberInterface; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\Processor; class TestProcessor implements Processor, TopicSubscriberInterface { - const TOPIC = 'test-topic'; + public const TOPIC = 'test-topic'; /** * @var Message diff --git a/pkg/enqueue-bundle/Tests/Functional/TopicRegistryTest.php b/pkg/enqueue-bundle/Tests/Functional/TopicRegistryTest.php deleted file mode 100644 index 0db559da0..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/TopicRegistryTest.php +++ /dev/null @@ -1,18 +0,0 @@ -container->get('enqueue.client.meta.topic_meta_registry'); - - $this->assertInstanceOf(TopicMetaRegistry::class, $connection); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/TopicsCommandTest.php b/pkg/enqueue-bundle/Tests/Functional/TopicsCommandTest.php deleted file mode 100644 index bb47f7119..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/TopicsCommandTest.php +++ /dev/null @@ -1,32 +0,0 @@ -container->get('enqueue.client.meta.topics_command'); - - $this->assertInstanceOf(TopicsCommand::class, $command); - } - - public function testShouldDisplayRegisteredTopics() - { - $command = $this->container->get('enqueue.client.meta.topics_command'); - - $tester = new CommandTester($command); - $tester->execute([]); - - $display = $tester->getDisplay(); - - $this->assertContains('__router__', $display); - $this->assertContains('enqueue.client.router_processor', $display); - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php b/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php new file mode 100644 index 000000000..7417412bd --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php @@ -0,0 +1,414 @@ +getContext()) { + $this->getContext()->close(); + } + + parent::tearDown(); + } + + public function provideEnqueueConfigs() + { + $baseDir = realpath(__DIR__.'/../../../../'); + + // guard + $this->assertNotEmpty($baseDir); + + $certDir = $baseDir.'/var/rabbitmq_certificates'; + $this->assertDirectoryExists($certDir); + + yield 'amqp_dsn' => [[ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]]; + + yield 'amqps_dsn' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('AMQPS_DSN'), + 'ssl_verify' => false, + 'ssl_cacert' => $certDir.'/cacert.pem', + 'ssl_cert' => $certDir.'/cert.pem', + 'ssl_key' => $certDir.'/key.pem', + ], + ], + ]]; + + yield 'dsn_as_env' => [[ + 'default' => [ + 'transport' => '%env(AMQP_DSN)%', + ], + ]]; + + yield 'dbal_dsn' => [[ + 'default' => [ + 'transport' => getenv('DOCTRINE_DSN'), + ], + ]]; + + yield 'rabbitmq_stomp' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('RABITMQ_STOMP_DSN'), + 'lazy' => false, + 'management_plugin_installed' => true, + ], + ], + ]]; + + yield 'predis_dsn' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('PREDIS_DSN'), + 'lazy' => false, + ], + ], + ]]; + + yield 'phpredis_dsn' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('PHPREDIS_DSN'), + 'lazy' => false, + ], + ], + ]]; + + yield 'fs_dsn' => [[ + 'default' => [ + 'transport' => 'file://'.sys_get_temp_dir(), + ], + ]]; + + yield 'sqs' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('SQS_DSN'), + ], + ], + ]]; + + yield 'sqs_client' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => 'sqs:', + 'service' => 'test.sqs_client', + 'factory_service' => 'test.sqs_custom_connection_factory_factory', + ], + ], + ]]; + + yield 'mongodb_dsn' => [[ + 'default' => [ + 'transport' => getenv('MONGO_DSN'), + ], + ]]; + + yield 'doctrine' => [[ + 'default' => [ + 'transport' => 'doctrine://custom', + ], + ]]; + + yield 'snsqs' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('SNSQS_DSN'), + ], + ], + ]]; + + // + // yield 'gps' => [[ + // 'transport' => [ + // 'dsn' => getenv('GPS_DSN'), + // ], + // ]]; + } + + /** + * @dataProvider provideEnqueueConfigs + */ + public function testProducerSendsEventMessage(array $enqueueConfig) + { + $this->customSetUp($enqueueConfig); + + $expectedBody = __METHOD__.time(); + + $this->getMessageProducer()->sendEvent(TestProcessor::TOPIC, $expectedBody); + + $consumer = $this->getContext()->createConsumer($this->getTestQueue()); + + $message = $consumer->receive(self::RECEIVE_TIMEOUT); + $this->assertInstanceOf(Message::class, $message); + $consumer->acknowledge($message); + + $this->assertSame($expectedBody, $message->getBody()); + } + + /** + * @dataProvider provideEnqueueConfigs + */ + public function testProducerSendsCommandMessage(array $enqueueConfig) + { + $this->customSetUp($enqueueConfig); + + $expectedBody = __METHOD__.time(); + + $this->getMessageProducer()->sendCommand(TestCommandProcessor::COMMAND, $expectedBody); + + $consumer = $this->getContext()->createConsumer($this->getTestQueue()); + + $message = $consumer->receive(self::RECEIVE_TIMEOUT); + $this->assertInstanceOf(Message::class, $message); + $consumer->acknowledge($message); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($expectedBody, $message->getBody()); + } + + public function testProducerSendsEventMessageViaProduceCommand() + { + $this->customSetUp([ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]); + + $expectedBody = __METHOD__.time(); + + $command = static::$container->get('test_enqueue.client.produce_command'); + $tester = new CommandTester($command); + $tester->execute([ + 'message' => $expectedBody, + '--topic' => TestProcessor::TOPIC, + '--client' => 'default', + ]); + + $consumer = $this->getContext()->createConsumer($this->getTestQueue()); + + $message = $consumer->receive(self::RECEIVE_TIMEOUT); + $this->assertInstanceOf(Message::class, $message); + $consumer->acknowledge($message); + + $this->assertSame($expectedBody, $message->getBody()); + } + + public function testProducerSendsCommandMessageViaProduceCommand() + { + $this->customSetUp([ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]); + + $expectedBody = __METHOD__.time(); + + $command = static::$container->get('test_enqueue.client.produce_command'); + $tester = new CommandTester($command); + $tester->execute([ + 'message' => $expectedBody, + '--command' => TestCommandProcessor::COMMAND, + '--client' => 'default', + ]); + + $consumer = $this->getContext()->createConsumer($this->getTestQueue()); + + $message = $consumer->receive(self::RECEIVE_TIMEOUT); + $this->assertInstanceOf(Message::class, $message); + $consumer->acknowledge($message); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($expectedBody, $message->getBody()); + } + + public function testShouldSetupBroker() + { + $this->customSetUp([ + 'default' => [ + 'transport' => 'file://'.sys_get_temp_dir(), + ], + ]); + + $command = static::$container->get('test_enqueue.client.setup_broker_command'); + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertSame("Broker set up\n", $tester->getDisplay()); + } + + public function testClientConsumeCommandMessagesFromExplicitlySetQueue() + { + $this->customSetUp([ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]); + + $command = static::$container->get('test_enqueue.client.consume_command'); + $processor = static::$container->get('test.message.command_processor'); + + $expectedBody = __METHOD__.time(); + + $this->getMessageProducer()->sendCommand(TestCommandProcessor::COMMAND, $expectedBody); + + $tester = new CommandTester($command); + $tester->execute([ + '--message-limit' => 2, + '--receive-timeout' => 100, + '--time-limit' => 'now + 2 seconds', + 'client-queue-names' => ['test'], + ]); + + $this->assertInstanceOf(Message::class, $processor->message); + $this->assertEquals($expectedBody, $processor->message->getBody()); + } + + public function testClientConsumeMessagesFromExplicitlySetQueue() + { + $this->customSetUp([ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]); + + $expectedBody = __METHOD__.time(); + + $command = static::$container->get('test_enqueue.client.consume_command'); + $processor = static::$container->get('test.message.processor'); + + $this->getMessageProducer()->sendEvent(TestProcessor::TOPIC, $expectedBody); + + $tester = new CommandTester($command); + $tester->execute([ + '--message-limit' => 2, + '--receive-timeout' => 100, + '--time-limit' => 'now + 2 seconds', + 'client-queue-names' => ['test'], + ]); + + $this->assertInstanceOf(Message::class, $processor->message); + $this->assertEquals($expectedBody, $processor->message->getBody()); + } + + public function testTransportConsumeCommandShouldConsumeOneMessage() + { + $this->customSetUp([ + 'default' => [ + 'transport' => getenv('AMQP_DSN'), + ], + ]); + + if ($this->getTestQueue() instanceof StompDestination) { + $this->markTestSkipped('The test fails with the exception Stomp\Exception\ErrorFrameException: Error "precondition_failed". '. + 'It happens because of the destination options are different from the one used while creating the dest. Nothing to do about it' + ); + } + + $expectedBody = __METHOD__.time(); + + $command = static::$container->get('test_enqueue.transport.consume_command'); + $processor = static::$container->get('test.message.processor'); + + $this->getMessageProducer()->sendEvent(TestProcessor::TOPIC, $expectedBody); + + $tester = new CommandTester($command); + $tester->execute([ + '--message-limit' => 1, + '--time-limit' => '+2sec', + '--receive-timeout' => 1000, + 'processor' => 'test.message.processor', + 'queues' => [$this->getTestQueue()->getQueueName()], + ]); + + $this->assertInstanceOf(Message::class, $processor->message); + $this->assertEquals($expectedBody, $processor->message->getBody()); + } + + public static function getKernelClass(): string + { + include_once __DIR__.'/App/CustomAppKernel.php'; + + return CustomAppKernel::class; + } + + protected function customSetUp(array $enqueueConfig) + { + static::$class = null; + + static::$client = static::createClient(['enqueue_config' => $enqueueConfig]); + static::$client->getKernel()->boot(); + static::$kernel = static::$client->getKernel(); + static::$container = static::$kernel->getContainer(); + + /** @var DriverInterface $driver */ + $driver = static::$container->get('test_enqueue.client.default.driver'); + $context = $this->getContext(); + + $driver->setupBroker(); + + try { + $context->purgeQueue($this->getTestQueue()); + } catch (PurgeQueueNotSupportedException $e) { + } + } + + /** + * @return Queue + */ + protected function getTestQueue() + { + /** @var DriverInterface $driver */ + $driver = static::$container->get('test_enqueue.client.default.driver'); + + return $driver->createQueue('test'); + } + + protected static function createKernel(array $options = []): CustomAppKernel + { + /** @var CustomAppKernel $kernel */ + $kernel = parent::createKernel($options); + + $kernel->setEnqueueConfig(isset($options['enqueue_config']) ? $options['enqueue_config'] : []); + + return $kernel; + } + + private function getMessageProducer(): ProducerInterface + { + return static::$container->get('test_enqueue.client.default.producer'); + } + + private function getContext(): Context + { + return static::$container->get('test_enqueue.transport.default.context'); + } +} diff --git a/pkg/enqueue-bundle/Tests/Functional/WebTestCase.php b/pkg/enqueue-bundle/Tests/Functional/WebTestCase.php index 01596fd67..6a348a9f3 100644 --- a/pkg/enqueue-bundle/Tests/Functional/WebTestCase.php +++ b/pkg/enqueue-bundle/Tests/Functional/WebTestCase.php @@ -3,6 +3,7 @@ namespace Enqueue\Bundle\Tests\Functional; use Enqueue\Bundle\Tests\Functional\App\AppKernel; +use Enqueue\Client\TraceableProducer; use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -12,29 +13,35 @@ abstract class WebTestCase extends BaseWebTestCase /** * @var Client */ - protected $client; + protected static $client; /** * @var ContainerInterface */ - protected $container; + protected static $container; - protected function setUp() + protected function setUp(): void { parent::setUp(); static::$class = null; + static::$client = static::createClient(); + static::$container = static::$kernel->getContainer(); - $this->client = static::createClient(); - $this->container = static::$kernel->getContainer(); + /** @var TraceableProducer $producer */ + $producer = static::$container->get('test_enqueue.client.default.traceable_producer'); + $producer->clearTraces(); } - /** - * @return string - */ - public static function getKernelClass() + protected function tearDown(): void + { + static::ensureKernelShutdown(); + static::$client = null; + } + + public static function getKernelClass(): string { - include_once __DIR__.'/app/AppKernel.php'; + include_once __DIR__.'/App/AppKernel.php'; return AppKernel::class; } diff --git a/pkg/enqueue-bundle/Tests/Functional/app/AmqpAppKernel.php b/pkg/enqueue-bundle/Tests/Functional/app/AmqpAppKernel.php deleted file mode 100644 index 803a5c4d4..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/app/AmqpAppKernel.php +++ /dev/null @@ -1,53 +0,0 @@ -load(__DIR__.'/config/amqp-config.yml'); - } - - protected function getContainerClass() - { - return parent::getContainerClass().'BundleAmqp'; - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/app/AppKernel.php b/pkg/enqueue-bundle/Tests/Functional/app/AppKernel.php deleted file mode 100644 index e2ed065a3..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/app/AppKernel.php +++ /dev/null @@ -1,53 +0,0 @@ -load(__DIR__.'/config/config.yml'); - } - - protected function getContainerClass() - { - return parent::getContainerClass().'BundleDefault'; - } -} diff --git a/pkg/enqueue-bundle/Tests/Functional/app/config/amqp-config.yml b/pkg/enqueue-bundle/Tests/Functional/app/config/amqp-config.yml deleted file mode 100644 index 312f81f52..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/app/config/amqp-config.yml +++ /dev/null @@ -1,43 +0,0 @@ -parameters: - locale: 'en' - secret: 'ThisTokenIsNotSoSecretChangeIt' - - -framework: - #esi: ~ - #translator: { fallback: "%locale%" } - test: ~ - assets: false - templating: false - session: - storage_id: session.storage.mock_file - secret: '%secret%' - router: { resource: '%kernel.root_dir%/config/routing.yml' } - default_locale: '%locale%' - -monolog: - handlers: - main: - type: 'null' - level: 'error' - -enqueue: - transport: - default: 'amqp' - amqp: - host: '%rabbitmq.host%' - port: '%rabbitmq.amqp.port%' - login: '%rabbitmq.user%' - password: '%rabbitmq.password%' - vhost: '%rabbitmq.vhost%' - client: - prefix: 'amqp' - router_topic: 'test' - router_queue: 'test' - default_processor_queue: 'test' - -services: - test.message.processor: - class: 'Enqueue\Bundle\Tests\Functional\TestProcessor' - tags: - - { name: 'enqueue.client.processor' } diff --git a/pkg/enqueue-bundle/Tests/Functional/app/config/config.yml b/pkg/enqueue-bundle/Tests/Functional/app/config/config.yml deleted file mode 100644 index b76ee45b2..000000000 --- a/pkg/enqueue-bundle/Tests/Functional/app/config/config.yml +++ /dev/null @@ -1,39 +0,0 @@ -parameters: - locale: 'en' - secret: 'ThisTokenIsNotSoSecretChangeIt' - - -framework: - #esi: ~ - #translator: { fallback: "%locale%" } - test: ~ - assets: false - templating: false - session: - storage_id: session.storage.mock_file - secret: '%secret%' - router: { resource: '%kernel.root_dir%/config/routing.yml' } - default_locale: '%locale%' - -monolog: - handlers: - main: - type: 'null' - level: 'error' - -doctrine: - dbal: - driver: "%db.driver%" - host: "%db.host%" - port: "%db.port%" - dbname: "%db.name%" - user: "%db.user%" - password: "%db.password%" - charset: UTF8 - -enqueue: - transport: - default: 'null' - 'null': ~ - client: ~ - job: true diff --git a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClearIdentityMapExtensionTest.php b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClearIdentityMapExtensionTest.php index 7bf9eaa36..7c5c2dd5d 100644 --- a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClearIdentityMapExtensionTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClearIdentityMapExtensionTest.php @@ -2,22 +2,20 @@ namespace Enqueue\Bundle\Tests\Unit\Consumption\Extension; -use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; use Enqueue\Bundle\Consumption\Extension\DoctrineClearIdentityMapExtension; -use Enqueue\Consumption\Context; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Processor; +use Enqueue\Consumption\Context\MessageReceived; +use Interop\Queue\Consumer; +use Interop\Queue\Context as InteropContext; +use Interop\Queue\Message; +use Interop\Queue\Processor; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Symfony\Bridge\Doctrine\RegistryInterface; -class DoctrineClearIdentityMapExtensionTest extends \PHPUnit_Framework_TestCase +class DoctrineClearIdentityMapExtensionTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new DoctrineClearIdentityMapExtension($this->createRegistryMock()); - } - public function testShouldClearIdentityMap() { $manager = $this->createManagerMock(); @@ -30,10 +28,10 @@ public function testShouldClearIdentityMap() $registry ->expects($this->once()) ->method('getManagers') - ->will($this->returnValue(['manager-name' => $manager])) + ->willReturn(['manager-name' => $manager]) ; - $context = $this->createPsrContext(); + $context = $this->createContext(); $context->getLogger() ->expects($this->once()) ->method('debug') @@ -41,34 +39,33 @@ public function testShouldClearIdentityMap() ; $extension = new DoctrineClearIdentityMapExtension($registry); - $extension->onPreReceived($context); + $extension->onMessageReceived($context); } - /** - * @return Context - */ - protected function createPsrContext() + protected function createContext(): MessageReceived { - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($this->createMock(LoggerInterface::class)); - $context->setPsrConsumer($this->createMock(Consumer::class)); - $context->setPsrProcessor($this->createMock(Processor::class)); - - return $context; + return new MessageReceived( + $this->createMock(InteropContext::class), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + $this->createMock(Processor::class), + 1, + $this->createMock(LoggerInterface::class) + ); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|RegistryInterface + * @return MockObject|ManagerRegistry */ - protected function createRegistryMock() + protected function createRegistryMock(): ManagerRegistry { - return $this->createMock(RegistryInterface::class); + return $this->createMock(ManagerRegistry::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|ObjectManager + * @return MockObject|ObjectManager */ - protected function createManagerMock() + protected function createManagerMock(): ObjectManager { return $this->createMock(ObjectManager::class); } diff --git a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClosedEntityManagerExtensionTest.php b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClosedEntityManagerExtensionTest.php new file mode 100644 index 000000000..8e7120325 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrineClosedEntityManagerExtensionTest.php @@ -0,0 +1,223 @@ +createManagerMock(true); + + $registry = $this->createRegistryMock([ + 'manager' => $manager, + ]); + + $message = new PreConsume( + $this->createMock(InteropContext::class), + $this->createMock(SubscriptionConsumer::class), + $this->createMock(LoggerInterface::class), + 1, + 2, + 3 + ); + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPreConsume($message); + + self::assertFalse($message->isExecutionInterrupted()); + } + + public function testOnPreConsumeShouldInterruptExecutionIfAManagerIsClosed() + { + $manager1 = $this->createManagerMock(true); + $manager2 = $this->createManagerMock(false); + + $registry = $this->createRegistryMock([ + 'manager1' => $manager1, + 'manager2' => $manager2, + ]); + + $message = new PreConsume( + $this->createMock(InteropContext::class), + $this->createMock(SubscriptionConsumer::class), + $this->createMock(LoggerInterface::class), + 1, + 2, + 3 + ); + $message->getLogger() + ->expects($this->once()) + ->method('debug') + ->with('[DoctrineClosedEntityManagerExtension] Interrupt execution as entity manager "manager2" has been closed') + ; + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPreConsume($message); + + self::assertTrue($message->isExecutionInterrupted()); + } + + public function testOnPostConsumeShouldNotInterruptExecution() + { + $manager = $this->createManagerMock(true); + + $registry = $this->createRegistryMock([ + 'manager' => $manager, + ]); + + $message = new PostConsume( + $this->createMock(InteropContext::class), + $this->createMock(SubscriptionConsumer::class), + 1, + 1, + 1, + $this->createMock(LoggerInterface::class) + ); + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPostConsume($message); + + self::assertFalse($message->isExecutionInterrupted()); + } + + public function testOnPostConsumeShouldInterruptExecutionIfAManagerIsClosed() + { + $manager1 = $this->createManagerMock(true); + $manager2 = $this->createManagerMock(false); + + $registry = $this->createRegistryMock([ + 'manager1' => $manager1, + 'manager2' => $manager2, + ]); + + $message = new PostConsume( + $this->createMock(InteropContext::class), + $this->createMock(SubscriptionConsumer::class), + 1, + 1, + 1, + $this->createMock(LoggerInterface::class) + ); + $message->getLogger() + ->expects($this->once()) + ->method('debug') + ->with('[DoctrineClosedEntityManagerExtension] Interrupt execution as entity manager "manager2" has been closed') + ; + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPostConsume($message); + + self::assertTrue($message->isExecutionInterrupted()); + } + + public function testOnPostReceivedShouldNotInterruptExecution() + { + $manager = $this->createManagerMock(true); + + $registry = $this->createRegistryMock([ + 'manager' => $manager, + ]); + + $message = new PostMessageReceived( + $this->createMock(InteropContext::class), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + $this->createMock(LoggerInterface::class) + ); + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPostMessageReceived($message); + + self::assertFalse($message->isExecutionInterrupted()); + } + + public function testOnPostReceivedShouldInterruptExecutionIfAManagerIsClosed() + { + $manager1 = $this->createManagerMock(true); + $manager2 = $this->createManagerMock(false); + + $registry = $this->createRegistryMock([ + 'manager1' => $manager1, + 'manager2' => $manager2, + ]); + + $message = new PostMessageReceived( + $this->createMock(InteropContext::class), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + $this->createMock(LoggerInterface::class) + ); + $message->getLogger() + ->expects($this->once()) + ->method('debug') + ->with('[DoctrineClosedEntityManagerExtension] Interrupt execution as entity manager "manager2" has been closed') + ; + + self::assertFalse($message->isExecutionInterrupted()); + + $extension = new DoctrineClosedEntityManagerExtension($registry); + $extension->onPostMessageReceived($message); + + self::assertTrue($message->isExecutionInterrupted()); + } + + /** + * @return MockObject|ManagerRegistry + */ + protected function createRegistryMock(array $managers): ManagerRegistry + { + $mock = $this->createMock(ManagerRegistry::class); + + $mock + ->expects($this->once()) + ->method('getManagers') + ->willReturn($managers) + ; + + return $mock; + } + + /** + * @return MockObject|EntityManagerInterface + */ + protected function createManagerMock(bool $open): EntityManagerInterface + { + $mock = $this->createMock(EntityManagerInterface::class); + + $mock + ->expects($this->once()) + ->method('isOpen') + ->willReturn($open) + ; + + return $mock; + } +} diff --git a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrinePingConnectionExtensionTest.php b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrinePingConnectionExtensionTest.php index 8e4f40bdf..36df82e52 100644 --- a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrinePingConnectionExtensionTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/DoctrinePingConnectionExtensionTest.php @@ -3,28 +3,39 @@ namespace Enqueue\Bundle\Tests\Unit\Consumption\Extension; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\Persistence\ManagerRegistry; use Enqueue\Bundle\Consumption\Extension\DoctrinePingConnectionExtension; -use Enqueue\Consumption\Context; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Processor; -use Psr\Log\LoggerInterface; -use Symfony\Bridge\Doctrine\RegistryInterface; - -class DoctrinePingConnectionExtensionTest extends \PHPUnit_Framework_TestCase -{ - public function testCouldBeConstructedWithRequiredAttributes() - { - new DoctrinePingConnectionExtension($this->createRegistryMock()); - } +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Test\TestLogger; +use Interop\Queue\Consumer; +use Interop\Queue\Context as InteropContext; +use Interop\Queue\Message; +use Interop\Queue\Processor; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +class DoctrinePingConnectionExtensionTest extends TestCase +{ public function testShouldNotReconnectIfConnectionIsOK() { $connection = $this->createConnectionMock(); $connection ->expects($this->once()) - ->method('ping') - ->will($this->returnValue(true)) + ->method('isConnected') + ->willReturn(true) + ; + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform->expects($this->once()) + ->method('getDummySelectSQL') + ->willReturn('dummy') + ; + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform) ; $connection ->expects($this->never()) @@ -35,21 +46,21 @@ public function testShouldNotReconnectIfConnectionIsOK() ->method('connect') ; - $context = $this->createPsrContext(); - $context->getLogger() - ->expects($this->never()) - ->method('debug') - ; + $context = $this->createContext(); $registry = $this->createRegistryMock(); $registry - ->expects($this->once()) + ->expects(self::once()) ->method('getConnections') - ->will($this->returnValue([$connection])) + ->willReturn([$connection]) ; $extension = new DoctrinePingConnectionExtension($registry); - $extension->onPreReceived($context); + $extension->onMessageReceived($context); + + /** @var TestLogger $logger */ + $logger = $context->getLogger(); + self::assertFalse($logger->hasDebugRecords()); } public function testShouldDoesReconnectIfConnectionFailed() @@ -57,8 +68,14 @@ public function testShouldDoesReconnectIfConnectionFailed() $connection = $this->createConnectionMock(); $connection ->expects($this->once()) - ->method('ping') - ->will($this->returnValue(false)) + ->method('isConnected') + ->willReturn(true) + ; + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willThrowException(new \Exception()) ; $connection ->expects($this->once()) @@ -69,54 +86,105 @@ public function testShouldDoesReconnectIfConnectionFailed() ->method('connect') ; - $context = $this->createPsrContext(); - $context->getLogger() - ->expects($this->at(0)) - ->method('debug') - ->with('[DoctrinePingConnectionExtension] Connection is not active trying to reconnect.') + $context = $this->createContext(); + + $registry = $this->createRegistryMock(); + $registry + ->expects($this->once()) + ->method('getConnections') + ->willReturn([$connection]) + ; + + $extension = new DoctrinePingConnectionExtension($registry); + $extension->onMessageReceived($context); + + /** @var TestLogger $logger */ + $logger = $context->getLogger(); + self::assertTrue( + $logger->hasDebugThatContains( + '[DoctrinePingConnectionExtension] Connection is not active trying to reconnect.' + ) + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[DoctrinePingConnectionExtension] Connection is active now.' + ) + ); + } + + public function testShouldSkipIfConnectionWasNotOpened() + { + $connection1 = $this->createConnectionMock(); + $connection1 + ->expects($this->once()) + ->method('isConnected') + ->willReturn(false) + ; + $connection1 + ->expects($this->never()) + ->method('getDatabasePlatform') + ; + + // 2nd connection was opened in the past + $connection2 = $this->createConnectionMock(); + $connection2 + ->expects($this->once()) + ->method('isConnected') + ->willReturn(true) ; - $context->getLogger() - ->expects($this->at(1)) - ->method('debug') - ->with('[DoctrinePingConnectionExtension] Connection is active now.') + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform->expects($this->once()) + ->method('getDummySelectSQL') + ->willReturn('dummy') + ; + + $connection2 + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform) ; + $context = $this->createContext(); + $registry = $this->createRegistryMock(); $registry ->expects($this->once()) ->method('getConnections') - ->will($this->returnValue([$connection])) + ->willReturn([$connection1, $connection2]) ; $extension = new DoctrinePingConnectionExtension($registry); - $extension->onPreReceived($context); + $extension->onMessageReceived($context); + + /** @var TestLogger $logger */ + $logger = $context->getLogger(); + $this->assertFalse($logger->hasDebugRecords()); } - /** - * @return Context - */ - protected function createPsrContext() + protected function createContext(): MessageReceived { - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($this->createMock(LoggerInterface::class)); - $context->setPsrConsumer($this->createMock(Consumer::class)); - $context->setPsrProcessor($this->createMock(Processor::class)); - - return $context; + return new MessageReceived( + $this->createMock(InteropContext::class), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + $this->createMock(Processor::class), + 1, + new TestLogger() + ); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|RegistryInterface + * @return MockObject|ManagerRegistry */ protected function createRegistryMock() { - return $this->createMock(RegistryInterface::class); + return $this->createMock(ManagerRegistry::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Connection + * @return MockObject|Connection */ - protected function createConnectionMock() + protected function createConnectionMock(): Connection { return $this->createMock(Connection::class); } diff --git a/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/ResetServicesExtensionTest.php b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/ResetServicesExtensionTest.php new file mode 100644 index 000000000..63282a255 --- /dev/null +++ b/pkg/enqueue-bundle/Tests/Unit/Consumption/Extension/ResetServicesExtensionTest.php @@ -0,0 +1,57 @@ +createResetterMock(); + $resetter + ->expects($this->once()) + ->method('reset') + ; + + $context = $this->createContext(); + $context->getLogger() + ->expects($this->once()) + ->method('debug') + ->with('[ResetServicesExtension] Resetting services.') + ; + + $extension = new ResetServicesExtension($resetter); + $extension->onPostMessageReceived($context); + } + + protected function createContext(): PostMessageReceived + { + return new PostMessageReceived( + $this->createMock(InteropContext::class), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + $this->createMock(Processor::class), + 1, + $this->createMock(LoggerInterface::class) + ); + } + + /** + * @return MockObject|ManagerRegistry + */ + protected function createResetterMock(): ServicesResetter + { + return $this->createMock(ServicesResetter::class); + } +} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/AddTopicMetaPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/AddTopicMetaPassTest.php deleted file mode 100644 index b7efc8551..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/AddTopicMetaPassTest.php +++ /dev/null @@ -1,74 +0,0 @@ -assertClassImplements(CompilerPassInterface::class, AddTopicMetaPass::class); - } - - public function testCouldBeConstructedWithoutAntArguments() - { - new AddTopicMetaPass([]); - } - - public function testCouldBeConstructedByCreateFactoryMethod() - { - $pass = AddTopicMetaPass::create(); - - $this->assertInstanceOf(AddTopicMetaPass::class, $pass); - } - - public function testShouldReturnSelfOnAdd() - { - $pass = AddTopicMetaPass::create(); - - $this->assertSame($pass, $pass->add('aTopic')); - } - - public function testShouldDoNothingIfContainerDoesNotHaveRegistryService() - { - $container = new ContainerBuilder(); - - $pass = AddTopicMetaPass::create() - ->add('fooTopic') - ->add('barTopic') - ; - - $pass->process($container); - } - - public function testShouldAddTopicsInRegistryKeepingPreviouslyAdded() - { - $container = new ContainerBuilder(); - - $registry = new Definition(null, [[ - 'bazTopic' => [], - ]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $registry); - - $pass = AddTopicMetaPass::create() - ->add('fooTopic') - ->add('barTopic') - ; - $pass->process($container); - - $expectedTopics = [ - 'bazTopic' => [], - 'fooTopic' => [], - 'barTopic' => [], - ]; - - $this->assertSame($expectedTopics, $registry->getArgument(0)); - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildClientRoutingPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildClientRoutingPassTest.php deleted file mode 100644 index 6c8472fb3..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildClientRoutingPassTest.php +++ /dev/null @@ -1,241 +0,0 @@ -createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - 'processorName' => 'processor', - 'queueName' => 'queue', - ]); - $container->setDefinition('processor', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic' => [ - ['processor', 'queue'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testThrowIfProcessorClassNameCouldNotBeFound() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition('notExistingClass'); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'processor', - ]); - $container->setDefinition('processor', $processor); - - $router = new Definition(); - $router->setArguments([null, []]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The class "notExistingClass" could not be found.'); - $pass->process($container); - } - - public function testShouldThrowExceptionIfTopicNameIsNotSet() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name is not set on message processor tag but it is required.'); - $pass->process($container); - } - - public function testShouldSetServiceIdAdProcessorIdIfIsNotSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - 'queueName' => 'queue', - ]); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic' => [ - ['processor-service-id', 'queue'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testShouldSetDefaultQueueIfNotSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - ]); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic' => [ - ['processor-service-id', 'aDefaultQueueName'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testShouldBuildRouteFromSubscriberIfOnlyTopicNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(OnlyTopicNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic-subscriber-name' => [ - ['processor-service-id', 'aDefaultQueueName'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testShouldBuildRouteFromSubscriberIfProcessorNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(ProcessorNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic-subscriber-name' => [ - ['subscriber-processor-name', 'aDefaultQueueName'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testShouldBuildRouteFromSubscriberIfQueueNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(QueueNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments([null, null, null]); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - - $expectedRoutes = [ - 'topic-subscriber-name' => [ - ['processor-service-id', 'subscriber-queue-name'], - ], - ]; - - $this->assertEquals($expectedRoutes, $router->getArgument(1)); - } - - public function testShouldThrowExceptionWhenTopicSubscriberConfigurationIsInvalid() - { - $this->setExpectedException(\LogicException::class, 'Topic subscriber configuration is invalid. "[12345]"'); - - $container = $this->createContainerBuilder(); - - $processor = new Definition(InvalidTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $router = new Definition(); - $router->setArguments(['', '']); - $container->setDefinition('enqueue.client.router_processor', $router); - - $pass = new BuildClientRoutingPass(); - $pass->process($container); - } - - /** - * @return ContainerBuilder - */ - private function createContainerBuilder() - { - $container = new ContainerBuilder(); - $container->setParameter('enqueue.client.default_queue_name', 'aDefaultQueueName'); - - return $container; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildExtensionsPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildExtensionsPassTest.php deleted file mode 100644 index 8e4aa54bc..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildExtensionsPassTest.php +++ /dev/null @@ -1,110 +0,0 @@ -assertClassImplements(CompilerPassInterface::class, BuildExtensionsPass::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new BuildExtensionsPass(); - } - - public function testShouldReplaceFirstArgumentOfExtensionsServiceConstructorWithTaggsExtensions() - { - $container = new ContainerBuilder(); - - $extensions = new Definition(); - $extensions->addArgument([]); - $container->setDefinition('enqueue.consumption.extensions', $extensions); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension'); - $container->setDefinition('foo_extension', $extension); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension'); - $container->setDefinition('bar_extension', $extension); - - $pass = new BuildExtensionsPass(); - $pass->process($container); - - $this->assertEquals( - [new Reference('foo_extension'), new Reference('bar_extension')], - $extensions->getArgument(0) - ); - } - - public function testShouldOrderExtensionsByPriority() - { - $container = new ContainerBuilder(); - - $extensions = new Definition(); - $extensions->addArgument([]); - $container->setDefinition('enqueue.consumption.extensions', $extensions); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension', ['priority' => 6]); - $container->setDefinition('foo_extension', $extension); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension', ['priority' => -5]); - $container->setDefinition('bar_extension', $extension); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension', ['priority' => 2]); - $container->setDefinition('baz_extension', $extension); - - $pass = new BuildExtensionsPass(); - $pass->process($container); - - $orderedExtensions = $extensions->getArgument(0); - - $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[0]); - $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[1]); - $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[2]); - } - - public function testShouldAssumePriorityZeroIfPriorityIsNotSet() - { - $container = new ContainerBuilder(); - - $extensions = new Definition(); - $extensions->addArgument([]); - $container->setDefinition('enqueue.consumption.extensions', $extensions); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension'); - $container->setDefinition('foo_extension', $extension); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension', ['priority' => 1]); - $container->setDefinition('bar_extension', $extension); - - $extension = new Definition(); - $extension->addTag('enqueue.consumption.extension', ['priority' => -1]); - $container->setDefinition('baz_extension', $extension); - - $pass = new BuildExtensionsPass(); - $pass->process($container); - - $orderedExtensions = $extensions->getArgument(0); - - $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[0]); - $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[1]); - $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[2]); - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildProcessorRegistryPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildProcessorRegistryPassTest.php deleted file mode 100644 index b210ff51d..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildProcessorRegistryPassTest.php +++ /dev/null @@ -1,180 +0,0 @@ -createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - 'processorName' => 'processor-name', - ]); - $container->setDefinition('processor-id', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - $pass->process($container); - - $expectedValue = [ - 'processor-name' => 'processor-id', - ]; - - $this->assertEquals($expectedValue, $processorRegistry->getArgument(0)); - } - - public function testThrowIfProcessorClassNameCouldNotBeFound() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition('notExistingClass'); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'processor', - ]); - $container->setDefinition('processor', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The class "notExistingClass" could not be found.'); - $pass->process($container); - } - - public function testShouldThrowExceptionIfTopicNameIsNotSet() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name is not set on message processor tag but it is required.'); - $pass->process($container); - } - - public function testShouldSetServiceIdAdProcessorIdIfIsNotSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - ]); - $container->setDefinition('processor-id', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - $pass->process($container); - - $expectedValue = [ - 'processor-id' => 'processor-id', - ]; - - $this->assertEquals($expectedValue, $processorRegistry->getArgument(0)); - } - - public function testShouldBuildRouteFromSubscriberIfOnlyTopicNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(OnlyTopicNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - $pass->process($container); - - $expectedValue = [ - 'processor-id' => 'processor-id', - ]; - - $this->assertEquals($expectedValue, $processorRegistry->getArgument(0)); - } - - public function testShouldBuildRouteFromSubscriberIfProcessorNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(ProcessorNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - $pass->process($container); - - $expectedValue = [ - 'subscriber-processor-name' => 'processor-id', - ]; - - $this->assertEquals($expectedValue, $processorRegistry->getArgument(0)); - } - - public function testShouldThrowExceptionWhenTopicSubscriberConfigurationIsInvalid() - { - $this->setExpectedException(\LogicException::class, 'Topic subscriber configuration is invalid. "[12345]"'); - - $container = $this->createContainerBuilder(); - - $processor = new Definition(InvalidTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $processorRegistry = new Definition(); - $processorRegistry->setArguments([]); - $container->setDefinition('enqueue.client.processor_registry', $processorRegistry); - - $pass = new BuildProcessorRegistryPass(); - $pass->process($container); - } - - /** - * @return ContainerBuilder - */ - private function createContainerBuilder() - { - $container = new ContainerBuilder(); - $container->setParameter('enqueue.client.default_queue_name', 'aDefaultQueueName'); - - return $container; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildQueueMetaRegistryPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildQueueMetaRegistryPassTest.php deleted file mode 100644 index 79665080e..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildQueueMetaRegistryPassTest.php +++ /dev/null @@ -1,204 +0,0 @@ -createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'processor', - ]); - $container->setDefinition('processor', $processor); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - } - - public function testThrowIfProcessorClassNameCouldNotBeFound() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition('notExistingClass'); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'processor', - ]); - $container->setDefinition('processor', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The class "notExistingClass" could not be found.'); - $pass->process($container); - } - - public function testShouldBuildQueueMetaRegistry() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'theProcessorName', - 'topicName' => 'aTopicName', - ]); - $container->setDefinition('processor', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'aDefaultQueueName' => ['processors' => ['theProcessorName']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - public function testShouldSetServiceIdAdProcessorIdIfIsNotSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'aTopicName', - ]); - $container->setDefinition('processor-service-id', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'aDefaultQueueName' => ['processors' => ['processor-service-id']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - public function testShouldSetQueueIfSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'queueName' => 'theClientQueueName', - 'topicName' => 'aTopicName', - ]); - $container->setDefinition('processor-service-id', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'theClientQueueName' => ['processors' => ['processor-service-id']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - public function testShouldBuildQueueFromSubscriberIfOnlyTopicNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(OnlyTopicNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'aDefaultQueueName' => ['processors' => ['processor-service-id']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - public function testShouldBuildQueueFromSubscriberIfProcessorNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(ProcessorNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'aDefaultQueueName' => ['processors' => ['subscriber-processor-name']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - public function testShouldBuildQueueFromSubscriberIfQueueNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(QueueNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-service-id', $processor); - - $registry = new Definition(); - $registry->setArguments([null, []]); - $container->setDefinition('enqueue.client.meta.queue_meta_registry', $registry); - - $pass = new BuildQueueMetaRegistryPass(); - $pass->process($container); - - $expectedQueues = [ - 'subscriber-queue-name' => ['processors' => ['processor-service-id']], - ]; - - $this->assertEquals($expectedQueues, $registry->getArgument(1)); - } - - /** - * @return ContainerBuilder - */ - private function createContainerBuilder() - { - $container = new ContainerBuilder(); - $container->setParameter('enqueue.client.default_queue_name', 'aDefaultQueueName'); - - return $container; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildTopicMetaSubscribersPassTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildTopicMetaSubscribersPassTest.php deleted file mode 100644 index affac5854..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/BuildTopicMetaSubscribersPassTest.php +++ /dev/null @@ -1,313 +0,0 @@ -createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - 'processorName' => 'processor-name', - ]); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'topic' => ['processors' => ['processor-name']], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testThrowIfProcessorClassNameCouldNotBeFound() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition('notExistingClass'); - $processor->addTag('enqueue.client.processor', [ - 'processorName' => 'processor', - ]); - $container->setDefinition('processor', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The class "notExistingClass" could not be found.'); - $pass->process($container); - } - - public function testShouldBuildTopicMetaSubscribersForOneTagAndSameMetaInRegistry() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - 'processorName' => 'barProcessorName', - ]); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[ - 'topic' => ['description' => 'aDescription', 'processors' => ['fooProcessorName']], - ]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'topic' => [ - 'description' => 'aDescription', - 'processors' => ['fooProcessorName', 'barProcessorName'], - ], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldBuildTopicMetaSubscribersForOneTagAndSameMetaInPlusAnotherRegistry() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'fooTopic', - 'processorName' => 'barProcessorName', - ]); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[ - 'fooTopic' => ['description' => 'aDescription', 'processors' => ['fooProcessorName']], - 'barTopic' => ['description' => 'aBarDescription'], - ]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'fooTopic' => [ - 'description' => 'aDescription', - 'processors' => ['fooProcessorName', 'barProcessorName'], - ], - 'barTopic' => ['description' => 'aBarDescription'], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldBuildTopicMetaSubscribersForTwoTagAndEmptyRegistry() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'fooTopic', - 'processorName' => 'fooProcessorName', - ]); - $container->setDefinition('processor-id', $processor); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'fooTopic', - 'processorName' => 'barProcessorName', - ]); - $container->setDefinition('another-processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'fooTopic' => [ - 'processors' => ['fooProcessorName', 'barProcessorName'], - ], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldBuildTopicMetaSubscribersForTwoTagSameMetaRegistry() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'fooTopic', - 'processorName' => 'fooProcessorName', - ]); - $container->setDefinition('processor-id', $processor); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'fooTopic', - 'processorName' => 'barProcessorName', - ]); - $container->setDefinition('another-processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[ - 'fooTopic' => ['description' => 'aDescription', 'processors' => ['bazProcessorName']], - ]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'fooTopic' => [ - 'description' => 'aDescription', - 'processors' => ['bazProcessorName', 'fooProcessorName', 'barProcessorName'], - ], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testThrowIfTopicNameNotSetOnTagAsAttribute() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', []); - $container->setDefinition('processor', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name is not set on message processor tag but it is required.'); - $pass->process($container); - } - - public function testShouldSetServiceIdAdProcessorIdIfIsNotSetInTag() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(\stdClass::class); - $processor->addTag('enqueue.client.processor', [ - 'topicName' => 'topic', - ]); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'topic' => ['processors' => ['processor-id']], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldBuildMetaFromSubscriberIfOnlyTopicNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(OnlyTopicNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'topic-subscriber-name' => ['processors' => ['processor-id']], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldBuildMetaFromSubscriberIfProcessorNameSpecified() - { - $container = $this->createContainerBuilder(); - - $processor = new Definition(ProcessorNameTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - - $expectedValue = [ - 'topic-subscriber-name' => ['processors' => ['subscriber-processor-name']], - ]; - - $this->assertEquals($expectedValue, $topicMetaRegistry->getArgument(0)); - } - - public function testShouldThrowExceptionWhenTopicSubscriberConfigurationIsInvalid() - { - $this->setExpectedException(\LogicException::class, 'Topic subscriber configuration is invalid. "[12345]"'); - - $container = $this->createContainerBuilder(); - - $processor = new Definition(InvalidTopicSubscriber::class); - $processor->addTag('enqueue.client.processor'); - $container->setDefinition('processor-id', $processor); - - $topicMetaRegistry = new Definition(); - $topicMetaRegistry->setArguments([[]]); - $container->setDefinition('enqueue.client.meta.topic_meta_registry', $topicMetaRegistry); - - $pass = new BuildTopicMetaSubscribersPass(); - $pass->process($container); - } - - /** - * @return ContainerBuilder - */ - private function createContainerBuilder() - { - $container = new ContainerBuilder(); - $container->setParameter('enqueue.client.default_queue_name', 'aDefaultQueueName'); - - return $container; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/InvalidTopicSubscriber.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/InvalidTopicSubscriber.php deleted file mode 100644 index 05ab5361d..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/InvalidTopicSubscriber.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - 'processorName' => 'subscriber-processor-name', - ], - ]; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/QueueNameTopicSubscriber.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/QueueNameTopicSubscriber.php deleted file mode 100644 index 6ed163d13..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/Compiler/Mock/QueueNameTopicSubscriber.php +++ /dev/null @@ -1,17 +0,0 @@ - [ - 'queueName' => 'subscriber-queue-name', - ], - ]; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index 535f6051b..5330cde82 100644 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -2,16 +2,16 @@ namespace Enqueue\Bundle\Tests\Unit\DependencyInjection; +use DMS\PHPUnitExtensions\ArraySubset\Assert; use Enqueue\Bundle\DependencyInjection\Configuration; -use Enqueue\Bundle\Tests\Unit\Mocks\FooTransportFactory; -use Enqueue\Symfony\DefaultTransportFactory; -use Enqueue\Symfony\NullTransportFactory; use Enqueue\Test\ClassExtensionTrait; +use PHPUnit\Framework\TestCase; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Exception\InvalidTypeException; use Symfony\Component\Config\Definition\Processor; -class ConfigurationTest extends \PHPUnit_Framework_TestCase +class ConfigurationTest extends TestCase { use ClassExtensionTrait; @@ -20,332 +20,643 @@ public function testShouldImplementConfigurationInterface() $this->assertClassImplements(ConfigurationInterface::class, Configuration::class); } - public function testCouldBeConstructedWithFactoriesAsFirstArgument() + public function testShouldBeFinal() { - new Configuration([]); + $this->assertClassFinal(Configuration::class); } - public function testThrowIfTransportNotConfigured() + public function testShouldProcessSeveralTransports() { - $this->setExpectedException( - InvalidConfigurationException::class, - 'The child node "transport" at path "enqueue" must be configured.' - ); + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => 'default:', + ], + 'foo' => [ + 'transport' => 'foo:', + ], + ]]); - $configuration = new Configuration([]); + $this->assertConfigEquals([ + 'default' => [ + 'transport' => [ + 'dsn' => 'default:', + ], + ], + 'foo' => [ + 'transport' => [ + 'dsn' => 'foo:', + ], + ], + ], $config); + } + + public function testTransportFactoryShouldValidateEachTransportAccordingToItsRules() + { + $configuration = new Configuration(true); $processor = new Processor(); - $processor->processConfiguration($configuration, [[]]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Both options factory_class and factory_service are set. Please choose one.'); + $processor->processConfiguration($configuration, [ + [ + 'default' => [ + 'transport' => [ + 'factory_class' => 'aClass', + 'factory_service' => 'aService', + ], + ], + ], + ]); } - public function testShouldInjectFooTransportFactoryConfig() + public function testShouldSetDefaultConfigurationForClient() { - $configuration = new Configuration([new FooTransportFactory()]); + $configuration = new Configuration(true); $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => 'null:', + 'client' => null, + ], + ]]); + + $this->assertConfigEquals([ + 'default' => [ + 'client' => [ + 'prefix' => 'enqueue', + 'app_name' => 'app', + 'router_processor' => null, + 'router_topic' => 'default', + 'router_queue' => 'default', + 'default_queue' => 'default', + 'traceable_producer' => true, + 'redelivered_delay_time' => 0, + ], + ], + ], $config); + } + + public function testThrowIfClientDriverOptionsIsNotArray() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + + $this->expectException(InvalidTypeException::class); + // Exception messages vary slightly between versions + $this->expectExceptionMessageMatches( + '/Invalid type for path "enqueue\.default\.client\.driver_options"\. Expected "?array"?, but got "?string"?/' + ); + $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'foo' => [ - 'foo_param' => 'aParam', + 'default' => [ + 'transport' => 'null:', + 'client' => [ + 'driver_options' => 'invalidOption', ], ], ]]); } - public function testThrowExceptionIfFooTransportConfigInvalid() + public function testShouldConfigureClientDriverOptions() { - $configuration = new Configuration([new FooTransportFactory()]); + $configuration = new Configuration(true); $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => 'null:', + 'client' => [ + 'driver_options' => [ + 'foo' => 'fooVal', + ], + ], + ], + ]]); - $this->setExpectedException( - InvalidConfigurationException::class, - 'The path "enqueue.transport.foo.foo_param" cannot contain an empty value, but got null.' - ); + $this->assertConfigEquals([ + 'default' => [ + 'client' => [ + 'prefix' => 'enqueue', + 'app_name' => 'app', + 'router_processor' => null, + 'router_topic' => 'default', + 'router_queue' => 'default', + 'default_queue' => 'default', + 'traceable_producer' => true, + 'driver_options' => [ + 'foo' => 'fooVal', + ], + ], + ], + ], $config); + } + public function testThrowExceptionIfRouterTopicIsEmpty() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "enqueue.default.client.router_topic" cannot contain an empty value, but got "".'); + + $configuration = new Configuration(true); + + $processor = new Processor(); $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'foo' => [ - 'foo_param' => null, + 'default' => [ + 'transport' => ['dsn' => 'null:'], + 'client' => [ + 'router_topic' => '', ], ], ]]); } - public function testShouldAllowConfigureDefaultTransport() + public function testThrowExceptionIfRouterQueueIsEmpty() { - $configuration = new Configuration([new DefaultTransportFactory()]); + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "enqueue.default.client.router_queue" cannot contain an empty value, but got "".'); + + $configuration = new Configuration(true); $processor = new Processor(); $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => ['alias' => 'foo'], + 'default' => [ + 'transport' => ['dsn' => 'null:'], + 'client' => [ + 'router_queue' => '', + ], + ], + ]]); + } + + public function testShouldThrowExceptionIfDefaultProcessorQueueIsEmpty() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "enqueue.default.client.default_queue" cannot contain an empty value, but got "".'); + $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => ['dsn' => 'null:'], + 'client' => [ + 'default_queue' => '', + ], ], ]]); } - public function testShouldAllowConfigureNullTransport() + public function testJobShouldBeDisabledByDefault() { - $configuration = new Configuration([new NullTransportFactory()]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'null' => true, + 'default' => [ + 'transport' => [], ], ]]); - $this->assertArraySubset([ - 'transport' => [ - 'null' => [], + Assert::assertArraySubset([ + 'default' => [ + 'job' => [ + 'enabled' => false, + ], ], ], $config); } - public function testShouldAllowConfigureSeveralTransportsSameTime() + public function testCouldEnableJob() { - $configuration = new Configuration([ - new NullTransportFactory(), - new DefaultTransportFactory(), - new FooTransportFactory(), - ]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => 'foo', - 'null' => true, - 'foo' => ['foo_param' => 'aParam'], + 'default' => [ + 'transport' => [], + 'job' => true, ], ]]); - $this->assertArraySubset([ - 'transport' => [ - 'default' => ['alias' => 'foo'], - 'null' => [], - 'foo' => ['foo_param' => 'aParam'], + Assert::assertArraySubset([ + 'default' => [ + 'job' => true, ], ], $config); } - public function testShouldSetDefaultConfigurationForClient() + public function testDoctrinePingConnectionExtensionShouldBeDisabledByDefault() { - $configuration = new Configuration([new DefaultTransportFactory()]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => ['alias' => 'foo'], + 'default' => [ + 'transport' => null, ], - 'client' => null, ]]); - $this->assertArraySubset([ - 'transport' => [ - 'default' => ['alias' => 'foo'], + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_ping_connection_extension' => false, + ], + ], + ], $config); + } + + public function testDoctrinePingConnectionExtensionCouldBeEnabled() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, + 'extensions' => [ + 'doctrine_ping_connection_extension' => true, + ], ], - 'client' => [ - 'prefix' => 'enqueue', - 'app_name' => 'app', - 'router_processor' => 'enqueue.client.router_processor', - 'router_topic' => 'router', - 'router_queue' => 'default', - 'default_processor_queue' => 'default', - 'traceable_producer' => false, - 'redelivered_delay_time' => 0, + ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_ping_connection_extension' => true, + ], ], ], $config); } - public function testThrowExceptionIfRouterTopicIsEmpty() + public function testDoctrineClearIdentityMapExtensionShouldBeDisabledByDefault() { - $this->setExpectedException( - InvalidConfigurationException::class, - 'The path "enqueue.client.router_topic" cannot contain an empty value, but got "".' - ); + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, + ], + ]]); - $configuration = new Configuration([new DefaultTransportFactory()]); + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_clear_identity_map_extension' => false, + ], + ], + ], $config); + } + + public function testDoctrineClearIdentityMapExtensionCouldBeEnabled() + { + $configuration = new Configuration(true); $processor = new Processor(); - $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => ['alias' => 'foo'], + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_clear_identity_map_extension' => true, + ], + ], + ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_clear_identity_map_extension' => true, + ], ], - 'client' => [ - 'router_topic' => '', + ], $config); + } + + public function testDoctrineOdmClearIdentityMapExtensionShouldBeDisabledByDefault() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, ], ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_odm_clear_identity_map_extension' => false, + ], + ], + ], $config); } - public function testThrowExceptionIfRouterQueueIsEmpty() + public function testDoctrineOdmClearIdentityMapExtensionCouldBeEnabled() { - $this->setExpectedException( - InvalidConfigurationException::class, - 'The path "enqueue.client.router_queue" cannot contain an empty value, but got "".' - ); + $configuration = new Configuration(true); - $configuration = new Configuration([new DefaultTransportFactory()]); + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_odm_clear_identity_map_extension' => true, + ], + ], + ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_odm_clear_identity_map_extension' => true, + ], + ], + ], $config); + } + + public function testDoctrineClosedEntityManagerExtensionShouldBeDisabledByDefault() + { + $configuration = new Configuration(true); $processor = new Processor(); - $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => ['alias' => 'foo'], + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, + ], + ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_closed_entity_manager_extension' => false, + ], ], - 'client' => [ - 'router_queue' => '', + ], $config); + } + + public function testDoctrineClosedEntityManagerExtensionCouldBeEnabled() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, + 'extensions' => [ + 'doctrine_closed_entity_manager_extension' => true, + ], ], ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'doctrine_closed_entity_manager_extension' => true, + ], + ], + ], $config); } - public function testShouldThrowExceptionIfDefaultProcessorQueueIsEmpty() + public function testResetServicesExtensionShouldBeDisabledByDefault() { - $configuration = new Configuration([new DefaultTransportFactory()]); + $configuration = new Configuration(true); $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => null, + ], + ]]); - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('The path "enqueue.client.default_processor_queue" cannot contain an empty value, but got "".'); - $processor->processConfiguration($configuration, [[ - 'transport' => [ - 'default' => ['alias' => 'foo'], + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'reset_services_extension' => false, + ], ], - 'client' => [ - 'default_processor_queue' => '', + ], $config); + } + + public function testResetServicesExtensionCouldBeEnabled() + { + $configuration = new Configuration(true); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reset_services_extension' => true, + ], ], ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'reset_services_extension' => true, + ], + ], + ], $config); } - public function testJobShouldBeDisabledByDefault() + public function testSignalExtensionShouldBeEnabledIfPcntlExtensionIsLoaded() { - $configuration = new Configuration([]); + $isLoaded = function_exists('pcntl_signal_dispatch'); + + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], + 'default' => [ + 'transport' => [], + ], ]]); - $this->assertArraySubset([ - 'job' => false, + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'signal_extension' => $isLoaded, + ], + ], ], $config); } - public function testCouldEnableJob() + public function testSignalExtensionCouldBeDisabled() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], - 'job' => true, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'signal_extension' => false, + ], + ], ]]); - $this->assertArraySubset([ - 'job' => true, + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'signal_extension' => false, + ], + ], ], $config); } - public function testDoctrinePingConnectionExtensionShouldBeDisabledByDefault() + public function testReplyExtensionShouldBeEnabledByDefault() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], + 'default' => [ + 'transport' => [], + ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'doctrine_ping_connection_extension' => false, + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'reply_extension' => true, + ], ], ], $config); } - public function testDoctrinePingConnectionExtensionCouldBeEnabled() + public function testReplyExtensionCouldBeDisabled() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], - 'extensions' => [ - 'doctrine_ping_connection_extension' => true, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reply_extension' => false, + ], ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'doctrine_ping_connection_extension' => true, + Assert::assertArraySubset([ + 'default' => [ + 'extensions' => [ + 'reply_extension' => false, + ], ], ], $config); } - public function testDoctrineClearIdentityMapExtensionShouldBeDisabledByDefault() + public function testShouldDisableAsyncEventsByDefault() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], + 'default' => [ + 'transport' => [], + ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'doctrine_clear_identity_map_extension' => false, + Assert::assertArraySubset([ + 'default' => [ + 'async_events' => [ + 'enabled' => false, + ], ], ], $config); } - public function testDoctrineClearIdentityMapExtensionCouldBeEnabled() + public function testShouldAllowEnableAsyncEvents() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); + + $config = $processor->processConfiguration($configuration, [[ + 'default' => [ + 'transport' => [], + 'async_events' => true, + ], + ]]); + + Assert::assertArraySubset([ + 'default' => [ + 'async_events' => [ + 'enabled' => true, + ], + ], + ], $config); + $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], - 'extensions' => [ - 'doctrine_clear_identity_map_extension' => true, + 'default' => [ + 'transport' => [], + 'async_events' => [ + 'enabled' => true, + ], ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'doctrine_clear_identity_map_extension' => true, + Assert::assertArraySubset([ + 'default' => [ + 'async_events' => [ + 'enabled' => true, + ], ], ], $config); } - public function testSignalExtensionShouldBeEnabledByDefault() + public function testShouldSetDefaultConfigurationForConsumption() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], + 'default' => [ + 'transport' => [], + ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'signal_extension' => true, + Assert::assertArraySubset([ + 'default' => [ + 'consumption' => [ + 'receive_timeout' => 10000, + ], ], ], $config); } - public function testSignalExtensionCouldBeDisabled() + public function testShouldAllowConfigureConsumption() { - $configuration = new Configuration([]); + $configuration = new Configuration(true); $processor = new Processor(); $config = $processor->processConfiguration($configuration, [[ - 'transport' => [], - 'extensions' => [ - 'signal_extension' => false, + 'default' => [ + 'transport' => [], + 'consumption' => [ + 'receive_timeout' => 456, + ], ], ]]); - $this->assertArraySubset([ - 'extensions' => [ - 'signal_extension' => false, + Assert::assertArraySubset([ + 'default' => [ + 'consumption' => [ + 'receive_timeout' => 456, + ], ], ], $config); } + + private function assertConfigEquals(array $expected, array $actual): void + { + Assert::assertArraySubset($expected, $actual, false, var_export($actual, true)); + } } diff --git a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/EnqueueExtensionTest.php b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/EnqueueExtensionTest.php index 525ed914e..6358bd24d 100644 --- a/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/EnqueueExtensionTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/DependencyInjection/EnqueueExtensionTest.php @@ -4,345 +4,321 @@ use Enqueue\Bundle\DependencyInjection\Configuration; use Enqueue\Bundle\DependencyInjection\EnqueueExtension; -use Enqueue\Bundle\Tests\Unit\Mocks\FooTransportFactory; -use Enqueue\Client\MessageProducer; -use Enqueue\Client\TraceableMessageProducer; -use Enqueue\Symfony\DefaultTransportFactory; -use Enqueue\Symfony\NullTransportFactory; +use Enqueue\Client\CommandSubscriberInterface; +use Enqueue\Client\Producer; +use Enqueue\Client\ProducerInterface; +use Enqueue\Client\TopicSubscriberInterface; +use Enqueue\Client\TraceableProducer; +use Enqueue\JobQueue\JobRunner; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullContext; +use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; -class EnqueueExtensionTest extends \PHPUnit_Framework_TestCase +class EnqueueExtensionTest extends TestCase { use ClassExtensionTrait; public function testShouldImplementConfigurationInterface() { - self::assertClassExtends(Extension::class, EnqueueExtension::class); + $this->assertClassExtends(Extension::class, EnqueueExtension::class); } - public function testCouldBeConstructedWithoutAnyArguments() + public function testShouldBeFinal() { - new EnqueueExtension(); + $this->assertClassFinal(EnqueueExtension::class); } - public function testThrowIfTransportFactoryNameEmpty() + public function testShouldRegisterConnectionFactory() { - $extension = new EnqueueExtension(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Transport factory name cannot be empty'); - - $extension->addTransportFactory(new FooTransportFactory(null)); - } + $container = $this->getContainerBuilder(true); - public function testThrowIfTransportFactoryWithSameNameAlreadyAdded() - { $extension = new EnqueueExtension(); - $extension->addTransportFactory(new FooTransportFactory('foo')); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Transport factory with such name already added. Name foo'); + $extension->load([[ + 'default' => [ + 'transport' => null, + ], + ]], $container); - $extension->addTransportFactory(new FooTransportFactory('foo')); + self::assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory')); + self::assertNotEmpty($container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()); } - public function testShouldConfigureNullTransport() + public function testShouldRegisterContext() { - $container = new ContainerBuilder(); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new NullTransportFactory()); $extension->load([[ - 'transport' => [ - 'null' => true, + 'default' => [ + 'transport' => null, ], ]], $container); - self::assertTrue($container->hasDefinition('enqueue.transport.null.context')); - $context = $container->getDefinition('enqueue.transport.null.context'); - self::assertEquals(NullContext::class, $context->getClass()); + self::assertTrue($container->hasDefinition('enqueue.transport.default.context')); + self::assertNotEmpty($container->getDefinition('enqueue.transport.default.context')->getFactory()); } - public function testShouldUseNullTransportAsDefault() + public function testShouldRegisterClientDriver() { - $container = new ContainerBuilder(); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new NullTransportFactory()); - $extension->addTransportFactory(new DefaultTransportFactory()); $extension->load([[ - 'transport' => [ - 'default' => 'null', - 'null' => true, + 'default' => [ + 'transport' => null, + 'client' => true, ], ]], $container); - self::assertEquals( - 'enqueue.transport.default.context', - (string) $container->getAlias('enqueue.transport.context') - ); - self::assertEquals( - 'enqueue.transport.null.context', - (string) $container->getAlias('enqueue.transport.default.context') - ); + self::assertTrue($container->hasDefinition('enqueue.client.default.driver')); + self::assertNotEmpty($container->getDefinition('enqueue.client.default.driver')->getFactory()); } - public function testShouldConfigureFooTransport() + public function testShouldLoadClientServicesWhenEnabled() { - $container = new ContainerBuilder(); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'transport' => [ - 'foo' => ['foo_param' => 'aParam'], + 'default' => [ + 'client' => null, + 'transport' => 'null:', ], ]], $container); - self::assertTrue($container->hasDefinition('foo.context')); - $context = $container->getDefinition('foo.context'); - self::assertEquals(\stdClass::class, $context->getClass()); - self::assertEquals([['foo_param' => 'aParam']], $context->getArguments()); + self::assertTrue($container->hasDefinition('enqueue.client.default.driver')); + self::assertTrue($container->hasDefinition('enqueue.client.default.config')); + self::assertTrue($container->hasAlias(ProducerInterface::class)); } - public function testShouldUseFooTransportAsDefault() + public function testShouldUseProducerByDefault() { - $container = new ContainerBuilder(); + $container = $this->getContainerBuilder(false); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new FooTransportFactory()); - $extension->addTransportFactory(new DefaultTransportFactory()); $extension->load([[ - 'transport' => [ - 'default' => 'foo', - 'foo' => ['foo_param' => 'aParam'], + 'default' => [ + 'client' => null, + 'transport' => 'null', ], ]], $container); - self::assertEquals( - 'enqueue.transport.default.context', - (string) $container->getAlias('enqueue.transport.context') - ); - self::assertEquals( - 'enqueue.transport.foo.context', - (string) $container->getAlias('enqueue.transport.default.context') - ); + $producer = $container->getDefinition('enqueue.client.default.producer'); + self::assertEquals(Producer::class, $producer->getClass()); } - public function testShouldLoadClientServicesWhenEnabled() + public function testShouldUseMessageProducerIfTraceableProducerOptionSetToFalseExplicitly() { - $container = new ContainerBuilder(); + $container = $this->getContainerBuilder(false); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'client' => null, - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, + 'default' => [ + 'client' => [ + 'traceable_producer' => false, ], + 'transport' => 'null:', ], ]], $container); - self::assertTrue($container->hasDefinition('enqueue.client.config')); - self::assertTrue($container->hasDefinition('enqueue.client.message_producer')); + $producer = $container->getDefinition('enqueue.client.default.producer'); + self::assertEquals(Producer::class, $producer->getClass()); } - public function testShouldUseMessageProducerByDefault() + public function testShouldUseTraceableMessageProducerIfDebugEnabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', false); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'client' => null, - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, - ], + 'default' => [ + 'transport' => 'null:', + 'client' => null, ], ]], $container); - $messageProducer = $container->getDefinition('enqueue.client.message_producer'); - self::assertEquals(MessageProducer::class, $messageProducer->getClass()); + $producer = $container->getDefinition('enqueue.client.default.traceable_producer'); + self::assertEquals(TraceableProducer::class, $producer->getClass()); + self::assertEquals( + ['enqueue.client.default.producer', null, 0], + $producer->getDecoratedService() + ); + + self::assertInstanceOf(Reference::class, $producer->getArgument(0)); + + $innerServiceName = 'enqueue.client.default.traceable_producer.inner'; + + self::assertEquals( + $innerServiceName, + (string) $producer->getArgument(0) + ); } - public function testShouldUseMessageProducerIfTraceableProducerOptionSetToFalseExplicitly() + public function testShouldNotUseTraceableMessageProducerIfDebugDisabledAndNotSetExplicitly() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', false); + $container = $this->getContainerBuilder(false); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'client' => [ - 'traceable_producer' => false, - ], - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, - ], + 'default' => [ + 'transport' => 'null:', ], ]], $container); - $messageProducer = $container->getDefinition('enqueue.client.message_producer'); - self::assertEquals(MessageProducer::class, $messageProducer->getClass()); + $this->assertFalse($container->hasDefinition('enqueue.client.default.traceable_producer')); } - public function testShouldUseTraceableMessageProducerIfTraceableProducerOptionSetToTrueExplicitly() + public function testShouldUseTraceableMessageProducerIfDebugDisabledButTraceableProducerOptionSetToTrueExplicitly() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(false); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'client' => [ - 'traceable_producer' => true, - ], - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, + 'default' => [ + 'client' => [ + 'traceable_producer' => true, ], + 'transport' => 'null:', ], ]], $container); - $messageProducer = $container->getDefinition('enqueue.client.traceable_message_producer'); - self::assertEquals(TraceableMessageProducer::class, $messageProducer->getClass()); + $producer = $container->getDefinition('enqueue.client.default.traceable_producer'); + self::assertEquals(TraceableProducer::class, $producer->getClass()); self::assertEquals( - ['enqueue.client.message_producer', null, 0], - $messageProducer->getDecoratedService() + ['enqueue.client.default.producer', null, 0], + $producer->getDecoratedService() ); - self::assertInstanceOf(Reference::class, $messageProducer->getArgument(0)); + self::assertInstanceOf(Reference::class, $producer->getArgument(0)); + + $innerServiceName = 'enqueue.client.default.traceable_producer.inner'; + self::assertEquals( - 'enqueue.client.traceable_message_producer.inner', - (string) $messageProducer->getArgument(0) + $innerServiceName, + (string) $producer->getArgument(0) ); } public function testShouldLoadDelayRedeliveredMessageExtensionIfRedeliveredDelayTimeGreaterThenZero() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, + 'default' => [ + 'transport' => 'null:', + 'client' => [ + 'redelivered_delay_time' => 12345, ], ], - 'client' => [ - 'redelivered_delay_time' => 12345, - ], ]], $container); - $extension = $container->getDefinition('enqueue.client.delay_redelivered_message_extension'); + $extension = $container->getDefinition('enqueue.client.default.delay_redelivered_message_extension'); self::assertEquals(12345, $extension->getArgument(1)); } public function testShouldNotLoadDelayRedeliveredMessageExtensionIfRedeliveredDelayTimeIsZero() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); - $extension->addTransportFactory(new DefaultTransportFactory()); - $extension->addTransportFactory(new FooTransportFactory()); $extension->load([[ - 'transport' => [ - 'default' => 'foo', - 'foo' => [ - 'foo_param' => true, + 'default' => [ + 'transport' => 'null:', + 'client' => [ + 'redelivered_delay_time' => 0, ], ], - 'client' => [ - 'redelivered_delay_time' => 0, - ], ]], $container); - $this->assertFalse($container->hasDefinition('enqueue.client.delay_redelivered_message_extension')); + $this->assertFalse($container->hasDefinition('enqueue.client.default.delay_redelivered_message_extension')); } public function testShouldLoadJobServicesIfEnabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'job' => true, + 'default' => [ + 'transport' => [], + 'client' => null, + 'job' => true, + ], ]], $container); - self::assertTrue($container->hasDefinition('enqueue.job.runner')); + self::assertTrue($container->hasDefinition(JobRunner::class)); + } + + public function testShouldThrowExceptionIfClientIsNotEnabledOnJobLoad() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Client is required for job-queue.'); + + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'job' => true, + ], + ]], $container); } public function testShouldNotLoadJobServicesIfDisabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'job' => false, + 'default' => [ + 'transport' => [], + 'job' => false, + ], ]], $container); - self::assertFalse($container->hasDefinition('enqueue.job.runner')); + self::assertFalse($container->hasDefinition(JobRunner::class)); } public function testShouldAllowGetConfiguration() { $extension = new EnqueueExtension(); - $configuration = $extension->getConfiguration([], new ContainerBuilder()); + $configuration = $extension->getConfiguration([], $this->getContainerBuilder(true)); self::assertInstanceOf(Configuration::class, $configuration); } public function testShouldLoadDoctrinePingConnectionExtensionServiceIfEnabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'doctrine_ping_connection_extension' => true, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_ping_connection_extension' => true, + ], ], ]], $container); @@ -351,15 +327,16 @@ public function testShouldLoadDoctrinePingConnectionExtensionServiceIfEnabled() public function testShouldNotLoadDoctrinePingConnectionExtensionServiceIfDisabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'doctrine_ping_connection_extension' => false, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_ping_connection_extension' => false, + ], ], ]], $container); @@ -368,15 +345,16 @@ public function testShouldNotLoadDoctrinePingConnectionExtensionServiceIfDisable public function testShouldLoadDoctrineClearIdentityMapExtensionServiceIfEnabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'doctrine_clear_identity_map_extension' => true, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_clear_identity_map_extension' => true, + ], ], ]], $container); @@ -385,32 +363,142 @@ public function testShouldLoadDoctrineClearIdentityMapExtensionServiceIfEnabled( public function testShouldNotLoadDoctrineClearIdentityMapExtensionServiceIfDisabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'doctrine_clear_identity_map_extension' => false, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_clear_identity_map_extension' => false, + ], ], ]], $container); self::assertFalse($container->hasDefinition('enqueue.consumption.doctrine_clear_identity_map_extension')); } + public function testShouldLoadDoctrineOdmClearIdentityMapExtensionServiceIfEnabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_odm_clear_identity_map_extension' => true, + ], + ], + ]], $container); + + self::assertTrue($container->hasDefinition('enqueue.consumption.doctrine_odm_clear_identity_map_extension')); + } + + public function testShouldNotLoadDoctrineOdmClearIdentityMapExtensionServiceIfDisabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_odm_clear_identity_map_extension' => false, + ], + ], + ]], $container); + + self::assertFalse($container->hasDefinition('enqueue.consumption.doctrine_odm_clear_identity_map_extension')); + } + + public function testShouldLoadDoctrineClosedEntityManagerExtensionServiceIfEnabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_closed_entity_manager_extension' => true, + ], + ], + ]], $container); + + self::assertTrue($container->hasDefinition('enqueue.consumption.doctrine_closed_entity_manager_extension')); + } + + public function testShouldNotLoadDoctrineClosedEntityManagerExtensionServiceIfDisabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'doctrine_closed_entity_manager_extension' => false, + ], + ], + ]], $container); + + self::assertFalse($container->hasDefinition('enqueue.consumption.doctrine_closed_entity_manager_extension')); + } + + public function testShouldLoadResetServicesExtensionServiceIfEnabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reset_services_extension' => true, + ], + ], + ]], $container); + + self::assertTrue($container->hasDefinition('enqueue.consumption.reset_services_extension')); + } + + public function testShouldNotLoadResetServicesExtensionServiceIfDisabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reset_services_extension' => false, + ], + ], + ]], $container); + + self::assertFalse($container->hasDefinition('enqueue.consumption.reset_services_extension')); + } + public function testShouldLoadSignalExtensionServiceIfEnabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'signal_extension' => true, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'signal_extension' => true, + ], ], ]], $container); @@ -419,18 +507,191 @@ public function testShouldLoadSignalExtensionServiceIfEnabled() public function testShouldNotLoadSignalExtensionServiceIfDisabled() { - $container = new ContainerBuilder(); - $container->setParameter('kernel.debug', true); + $container = $this->getContainerBuilder(true); $extension = new EnqueueExtension(); $extension->load([[ - 'transport' => [], - 'extensions' => [ - 'signal_extension' => false, + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'signal_extension' => false, + ], ], ]], $container); self::assertFalse($container->hasDefinition('enqueue.consumption.signal_extension')); } + + public function testShouldLoadReplyExtensionServiceIfEnabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reply_extension' => true, + ], + ], + ]], $container); + + self::assertTrue($container->hasDefinition('enqueue.consumption.reply_extension')); + } + + public function testShouldNotLoadReplyExtensionServiceIfDisabled() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'transport' => [], + 'extensions' => [ + 'reply_extension' => false, + ], + ], + ]], $container); + + self::assertFalse($container->hasDefinition('enqueue.consumption.reply_extension')); + } + + public function testShouldAddJobQueueEntityMapping() + { + $container = $this->getContainerBuilder(true); + $container->setParameter('kernel.bundles', ['DoctrineBundle' => true]); + $container->prependExtensionConfig('doctrine', ['dbal' => true]); + + $extension = new EnqueueExtension(); + + $extension->prepend($container); + + $config = $container->getExtensionConfig('doctrine'); + + $this->assertSame(['dbal' => true], $config[1]); + $this->assertNotEmpty($config[0]['orm']['mappings']['enqueue_job_queue']); + } + + public function testShouldNotAddJobQueueEntityMappingIfDoctrineBundleIsNotRegistered() + { + $container = $this->getContainerBuilder(true); + $container->setParameter('kernel.bundles', []); + + $extension = new EnqueueExtension(); + + $extension->prepend($container); + + $this->assertSame([], $container->getExtensionConfig('doctrine')); + } + + public function testShouldConfigureQueueConsumer() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + $extension->load([[ + 'default' => [ + 'client' => [], + 'transport' => [ + ], + 'consumption' => [ + 'receive_timeout' => 456, + ], + ], + ]], $container); + + $def = $container->getDefinition('enqueue.transport.default.queue_consumer'); + $this->assertSame('%enqueue.transport.default.receive_timeout%', $def->getArgument(4)); + + $this->assertSame(456, $container->getParameter('enqueue.transport.default.receive_timeout')); + + $def = $container->getDefinition('enqueue.client.default.queue_consumer'); + $this->assertSame(456, $def->getArgument(4)); + } + + public function testShouldSetPropertyWithAllConfiguredTransports() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + $extension->load([[ + 'default' => [ + 'transport' => 'default:', + 'client' => [], + ], + 'foo' => [ + 'transport' => 'foo:', + 'client' => [], + ], + 'bar' => [ + 'transport' => 'bar:', + 'client' => [], + ], + ]], $container); + + $this->assertTrue($container->hasParameter('enqueue.transports')); + $this->assertEquals(['default', 'foo', 'bar'], $container->getParameter('enqueue.transports')); + } + + public function testShouldSetPropertyWithAllConfiguredClients() + { + $container = $this->getContainerBuilder(true); + + $extension = new EnqueueExtension(); + $extension->load([[ + 'default' => [ + 'transport' => 'default:', + 'client' => [], + ], + 'foo' => [ + 'transport' => 'foo:', + ], + 'bar' => [ + 'transport' => 'bar:', + 'client' => [], + ], + ]], $container); + + $this->assertTrue($container->hasParameter('enqueue.clients')); + $this->assertEquals(['default', 'bar'], $container->getParameter('enqueue.clients')); + } + + public function testShouldLoadProcessAutoconfigureChildDefinition() + { + $container = $this->getContainerBuilder(true); + $extension = new EnqueueExtension(); + + $extension->load([[ + 'default' => [ + 'client' => [], + 'transport' => [], + ], + ]], $container); + + $autoconfigured = $container->getAutoconfiguredInstanceof(); + + self::assertArrayHasKey(CommandSubscriberInterface::class, $autoconfigured); + self::assertTrue($autoconfigured[CommandSubscriberInterface::class]->hasTag('enqueue.command_subscriber')); + self::assertTrue($autoconfigured[CommandSubscriberInterface::class]->isPublic()); + + self::assertArrayHasKey(TopicSubscriberInterface::class, $autoconfigured); + self::assertTrue($autoconfigured[TopicSubscriberInterface::class]->hasTag('enqueue.topic_subscriber')); + self::assertTrue($autoconfigured[TopicSubscriberInterface::class]->isPublic()); + } + + /** + * @param bool $debug + * + * @return ContainerBuilder + */ + private function getContainerBuilder($debug) + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', $debug); + + return $container; + } } diff --git a/pkg/enqueue-bundle/Tests/Unit/EnqueueBundleTest.php b/pkg/enqueue-bundle/Tests/Unit/EnqueueBundleTest.php index 72d931360..7d5b0232b 100644 --- a/pkg/enqueue-bundle/Tests/Unit/EnqueueBundleTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/EnqueueBundleTest.php @@ -2,24 +2,12 @@ namespace Enqueue\Bundle\Tests\Unit; -use Enqueue\AmqpExt\Symfony\AmqpTransportFactory; -use Enqueue\AmqpExt\Symfony\RabbitMqTransportFactory; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildClientRoutingPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildExtensionsPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildProcessorRegistryPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildQueueMetaRegistryPass; -use Enqueue\Bundle\DependencyInjection\Compiler\BuildTopicMetaSubscribersPass; -use Enqueue\Bundle\DependencyInjection\EnqueueExtension; use Enqueue\Bundle\EnqueueBundle; -use Enqueue\Stomp\Symfony\RabbitMqStompTransportFactory; -use Enqueue\Stomp\Symfony\StompTransportFactory; -use Enqueue\Symfony\DefaultTransportFactory; -use Enqueue\Symfony\NullTransportFactory; use Enqueue\Test\ClassExtensionTrait; -use Symfony\Component\DependencyInjection\ContainerBuilder; +use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Bundle\Bundle; -class EnqueueBundleTest extends \PHPUnit_Framework_TestCase +class EnqueueBundleTest extends TestCase { use ClassExtensionTrait; @@ -27,135 +15,4 @@ public function testShouldExtendBundleClass() { $this->assertClassExtends(Bundle::class, EnqueueBundle::class); } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new EnqueueBundle(); - } - - public function testShouldRegisterExpectedCompilerPasses() - { - $extensionMock = $this->createMock(EnqueueExtension::class); - - $container = $this->createMock(ContainerBuilder::class); - $container - ->expects($this->at(0)) - ->method('addCompilerPass') - ->with($this->isInstanceOf(BuildExtensionsPass::class)) - ; - $container - ->expects($this->at(1)) - ->method('addCompilerPass') - ->with($this->isInstanceOf(BuildClientRoutingPass::class)) - ; - $container - ->expects($this->at(2)) - ->method('addCompilerPass') - ->with($this->isInstanceOf(BuildProcessorRegistryPass::class)) - ; - $container - ->expects($this->at(3)) - ->method('addCompilerPass') - ->with($this->isInstanceOf(BuildTopicMetaSubscribersPass::class)) - ; - $container - ->expects($this->at(4)) - ->method('addCompilerPass') - ->with($this->isInstanceOf(BuildQueueMetaRegistryPass::class)) - ; - $container - ->expects($this->at(5)) - ->method('getExtension') - ->willReturn($extensionMock) - ; - - $bundle = new EnqueueBundle(); - $bundle->build($container); - } - - public function testShouldRegisterDefaultAndNullTransportFactories() - { - $extensionMock = $this->createEnqueueExtensionMock(); - - $container = new ContainerBuilder(); - $container->registerExtension($extensionMock); - - $extensionMock - ->expects($this->at(0)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(DefaultTransportFactory::class)) - ; - $extensionMock - ->expects($this->at(1)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(NullTransportFactory::class)) - ; - - $bundle = new EnqueueBundle(); - $bundle->build($container); - } - - public function testShouldRegisterStompAndRabbitMqStompTransportFactories() - { - $extensionMock = $this->createEnqueueExtensionMock(); - - $container = new ContainerBuilder(); - $container->registerExtension($extensionMock); - - $extensionMock - ->expects($this->at(2)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(StompTransportFactory::class)) - ; - $extensionMock - ->expects($this->at(3)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(RabbitMqStompTransportFactory::class)) - ; - - $bundle = new EnqueueBundle(); - $bundle->build($container); - } - - public function testShouldRegisterAmqpAndRabbitMqAmqpTransportFactories() - { - $extensionMock = $this->createEnqueueExtensionMock(); - - $container = new ContainerBuilder(); - $container->registerExtension($extensionMock); - - $extensionMock - ->expects($this->at(4)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(AmqpTransportFactory::class)) - ; - $extensionMock - ->expects($this->at(5)) - ->method('addTransportFactory') - ->with($this->isInstanceOf(RabbitMqTransportFactory::class)) - ; - - $bundle = new EnqueueBundle(); - $bundle->build($container); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|EnqueueExtension - */ - private function createEnqueueExtensionMock() - { - $extensionMock = $this->createMock(EnqueueExtension::class); - $extensionMock - ->expects($this->once()) - ->method('getAlias') - ->willReturn('enqueue') - ; - $extensionMock - ->expects($this->once()) - ->method('getNamespace') - ->willReturn(false) - ; - - return $extensionMock; - } } diff --git a/pkg/enqueue-bundle/Tests/Unit/Mocks/FooTransportFactory.php b/pkg/enqueue-bundle/Tests/Unit/Mocks/FooTransportFactory.php deleted file mode 100644 index 89ee65995..000000000 --- a/pkg/enqueue-bundle/Tests/Unit/Mocks/FooTransportFactory.php +++ /dev/null @@ -1,64 +0,0 @@ -name = $name; - } - - /** - * {@inheritdoc} - */ - public function addConfiguration(ArrayNodeDefinition $builder) - { - $builder - ->children() - ->scalarNode('foo_param')->isRequired()->cannotBeEmpty()->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createContext(ContainerBuilder $container, array $config) - { - $connectionId = 'foo.context'; - - $container->setDefinition($connectionId, new Definition(\stdClass::class, [$config])); - - return $connectionId; - } - - public function createDriver(ContainerBuilder $container, array $config) - { - $driverId = 'foo.driver'; - - $container->setDefinition($driverId, new Definition(\stdClass::class, [$config])); - - return $driverId; - } - - /** - * {@inheritdoc} - */ - public function getName() - { - return $this->name; - } -} diff --git a/pkg/enqueue-bundle/Tests/Unit/Profiler/MessageQueueCollectorTest.php b/pkg/enqueue-bundle/Tests/Unit/Profiler/MessageQueueCollectorTest.php index 032ca6179..d6d638d75 100644 --- a/pkg/enqueue-bundle/Tests/Unit/Profiler/MessageQueueCollectorTest.php +++ b/pkg/enqueue-bundle/Tests/Unit/Profiler/MessageQueueCollectorTest.php @@ -2,16 +2,19 @@ namespace Enqueue\Bundle\Tests\Unit\Profiler; +use DMS\PHPUnitExtensions\ArraySubset\Assert; use Enqueue\Bundle\Profiler\MessageQueueCollector; use Enqueue\Client\MessagePriority; -use Enqueue\Client\MessageProducerInterface; -use Enqueue\Client\TraceableMessageProducer; +use Enqueue\Client\ProducerInterface; +use Enqueue\Client\TraceableProducer; use Enqueue\Test\ClassExtensionTrait; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; -class MessageQueueCollectorTest extends \PHPUnit_Framework_TestCase +class MessageQueueCollectorTest extends TestCase { use ClassExtensionTrait; @@ -20,86 +23,130 @@ public function testShouldExtendDataCollectorClass() $this->assertClassExtends(DataCollector::class, MessageQueueCollector::class); } - public function testCouldBeConstructedWithMessageProducerAsFirstArgument() - { - new MessageQueueCollector($this->createMessageProducerMock()); - } - public function testShouldReturnExpectedName() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); + $collector = new MessageQueueCollector(); $this->assertEquals('enqueue.message_queue', $collector->getName()); } - public function testShouldReturnEmptySentMessageArrayIfNotTraceableMessageProducer() + public function testShouldReturnEmptySentMessageArrayIfNotTraceableProducer() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); + $collector = new MessageQueueCollector(); + $collector->addProducer('default', $this->createProducerMock()); $collector->collect(new Request(), new Response()); $this->assertSame([], $collector->getSentMessages()); } - public function testShouldReturnSentMessageArrayTakenFromTraceableMessageProducer() + public function testShouldReturnSentMessageArrayTakenFromTraceableProducers() { - $producerMock = $this->createTraceableMessageProducerMock(); - $producerMock - ->expects($this->once()) - ->method('getTraces') - ->willReturn([['foo'], ['bar']]); + $producer1 = new TraceableProducer($this->createProducerMock()); + $producer1->sendEvent('fooTopic1', 'fooMessage'); + $producer1->sendCommand('barCommand1', 'barMessage'); - $collector = new MessageQueueCollector($producerMock); + $producer2 = new TraceableProducer($this->createProducerMock()); + $producer2->sendEvent('fooTopic2', 'fooMessage'); + + $collector = new MessageQueueCollector(); + $collector->addProducer('foo', $producer1); + $collector->addProducer('bar', $producer2); $collector->collect(new Request(), new Response()); - $this->assertSame([['foo'], ['bar']], $collector->getSentMessages()); + Assert::assertArraySubset( + [ + 'foo' => [ + [ + 'topic' => 'fooTopic1', + 'command' => null, + 'body' => 'fooMessage', + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + [ + 'topic' => null, + 'command' => 'barCommand1', + 'body' => 'barMessage', + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], + 'bar' => [ + [ + 'topic' => 'fooTopic2', + 'command' => null, + 'body' => 'fooMessage', + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], + ], + $collector->getSentMessages() + ); } public function testShouldPrettyPrintKnownPriority() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); + $collector = new MessageQueueCollector(); $this->assertEquals('normal', $collector->prettyPrintPriority(MessagePriority::NORMAL)); } public function testShouldPrettyPrintUnknownPriority() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); + $collector = new MessageQueueCollector(); $this->assertEquals('unknownPriority', $collector->prettyPrintPriority('unknownPriority')); } - public function testShouldPrettyPrintScalarMessage() + public function testShouldEnsureStringKeepStringSame() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); + $collector = new MessageQueueCollector(); - $this->assertEquals('foo', $collector->prettyPrintMessage('foo')); - $this->assertEquals('<p>', $collector->prettyPrintMessage('

')); + $this->assertEquals('foo', $collector->ensureString('foo')); + $this->assertEquals('bar baz', $collector->ensureString('bar baz')); } - public function testShouldPrettyPrintArrayMessage() + public function testShouldEnsureStringEncodeArrayToJson() { - $collector = new MessageQueueCollector($this->createMessageProducerMock()); - - $expected = "[\n "foo",\n "bar"\n]"; + $collector = new MessageQueueCollector(); - $this->assertEquals($expected, $collector->prettyPrintMessage(['foo', 'bar'])); + $this->assertEquals('["foo","bar"]', $collector->ensureString(['foo', 'bar'])); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducerInterface + * @return MockObject|ProducerInterface */ - protected function createMessageProducerMock() + protected function createProducerMock() { - return $this->createMock(MessageProducerInterface::class); + return $this->createMock(ProducerInterface::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|TraceableMessageProducer + * @return MockObject|TraceableProducer */ - protected function createTraceableMessageProducerMock() + protected function createTraceableProducerMock() { - return $this->createMock(TraceableMessageProducer::class); + return $this->createMock(TraceableProducer::class); } } diff --git a/pkg/enqueue-bundle/Tests/fix_composer_json.php b/pkg/enqueue-bundle/Tests/fix_composer_json.php new file mode 100644 index 000000000..5c80237ea --- /dev/null +++ b/pkg/enqueue-bundle/Tests/fix_composer_json.php @@ -0,0 +1,10 @@ +=5.6", - "symfony/framework-bundle": "^2.8|^3", - "enqueue/enqueue": "^0.2" + "php": "^8.1", + "symfony/framework-bundle": "^6.2|^7.0", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "enqueue/enqueue": "^0.10", + "enqueue/null": "^0.10" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" }, "require-dev": { - "phpunit/phpunit": "~5.5", - "enqueue/stomp": "^0.2", - "enqueue/amqp-ext": "^0.2", - "enqueue/job-queue": "^0.2", - "enqueue/test": "^0.2", - "doctrine/doctrine-bundle": "~1.2", - "symfony/monolog-bundle": "^2.8|^3", - "symfony/browser-kit": "^2.8|^3", - "symfony/expression-language": "^2.8|^3" + "phpunit/phpunit": "^9.5", + "enqueue/stomp": "0.10.x-dev", + "enqueue/amqp-ext": "0.10.x-dev", + "enqueue/amqp-lib": "0.10.x-dev", + "enqueue/amqp-bunny": "0.10.x-dev", + "enqueue/job-queue": "0.10.x-dev", + "enqueue/fs": "0.10.x-dev", + "enqueue/redis": "0.10.x-dev", + "enqueue/dbal": "0.10.x-dev", + "enqueue/sqs": "0.10.x-dev", + "enqueue/gps": "0.10.x-dev", + "enqueue/test": "0.10.x-dev", + "enqueue/async-event-dispatcher": "0.10.x-dev", + "enqueue/async-command": "0.10.x-dev", + "php-amqplib/php-amqplib": "^3.0", + "doctrine/doctrine-bundle": "^2.3.2", + "doctrine/mongodb-odm-bundle": "^3.5|^4.3|^5.0", + "alcaeus/mongo-php-adapter": "^1.0", + "symfony/browser-kit": "^6.2|^7.0", + "symfony/expression-language": "^6.2|^7.0", + "symfony/validator": "^6.2|^7.0", + "symfony/yaml": "^6.2|^7.0" }, "suggest": { - "enqueue/amqp-ext": "Message queue AMQP transport", - "enqueue/stomp": "Message queue STOMP transport" + "enqueue/async-command": "If want to run Symfony command via message queue", + "enqueue/async-event-dispatcher": "If you want dispatch and process events asynchronously" }, "autoload": { "psr-4": { "Enqueue\\Bundle\\": "" }, @@ -36,10 +54,14 @@ "/Tests/" ] }, - "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } } } diff --git a/pkg/enqueue-bundle/phpunit.xml.dist b/pkg/enqueue-bundle/phpunit.xml.dist index ac0770ea9..974d2c3f5 100644 --- a/pkg/enqueue-bundle/phpunit.xml.dist +++ b/pkg/enqueue-bundle/phpunit.xml.dist @@ -1,16 +1,11 @@ - + diff --git a/pkg/enqueue/.gitattributes b/pkg/enqueue/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/enqueue/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/enqueue/.github/workflows/ci.yml b/pkg/enqueue/.github/workflows/ci.yml new file mode 100644 index 000000000..28a46e908 --- /dev/null +++ b/pkg/enqueue/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mongodb + + - run: php Tests/fix_composer_json.php + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/enqueue/.travis.yml b/pkg/enqueue/.travis.yml deleted file mode 100644 index 42374ddc7..000000000 --- a/pkg/enqueue/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer self-update - - composer install --prefer-source - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/enqueue/ArrayProcessorRegistry.php b/pkg/enqueue/ArrayProcessorRegistry.php new file mode 100644 index 000000000..592908c51 --- /dev/null +++ b/pkg/enqueue/ArrayProcessorRegistry.php @@ -0,0 +1,38 @@ +processors = []; + array_walk($processors, function (Processor $processor, string $key) { + $this->processors[$key] = $processor; + }); + } + + public function add(string $name, Processor $processor): void + { + $this->processors[$name] = $processor; + } + + public function get(string $processorName): Processor + { + if (false == isset($this->processors[$processorName])) { + throw new \LogicException(sprintf('Processor was not found. processorName: "%s"', $processorName)); + } + + return $this->processors[$processorName]; + } +} diff --git a/pkg/enqueue/Client/ArrayProcessorRegistry.php b/pkg/enqueue/Client/ArrayProcessorRegistry.php deleted file mode 100644 index ab8278893..000000000 --- a/pkg/enqueue/Client/ArrayProcessorRegistry.php +++ /dev/null @@ -1,42 +0,0 @@ -processors = $processors; - } - - /** - * @param string $name - * @param Processor $processor - */ - public function add($name, Processor $processor) - { - $this->processors[$name] = $processor; - } - - /** - * {@inheritdoc} - */ - public function get($processorName) - { - if (false == isset($this->processors[$processorName])) { - throw new \LogicException(sprintf('Processor was not found. processorName: "%s"', $processorName)); - } - - return $this->processors[$processorName]; - } -} diff --git a/pkg/enqueue/Client/ChainExtension.php b/pkg/enqueue/Client/ChainExtension.php new file mode 100644 index 000000000..655b75f6a --- /dev/null +++ b/pkg/enqueue/Client/ChainExtension.php @@ -0,0 +1,102 @@ +preSendEventExtensions = []; + $this->preSendCommandExtensions = []; + $this->driverPreSendExtensions = []; + $this->postSendExtensions = []; + + array_walk($extensions, function ($extension) { + if ($extension instanceof ExtensionInterface) { + $this->preSendEventExtensions[] = $extension; + $this->preSendCommandExtensions[] = $extension; + $this->driverPreSendExtensions[] = $extension; + $this->postSendExtensions[] = $extension; + + return; + } + + $extensionValid = false; + if ($extension instanceof PreSendEventExtensionInterface) { + $this->preSendEventExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PreSendCommandExtensionInterface) { + $this->preSendCommandExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof DriverPreSendExtensionInterface) { + $this->driverPreSendExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PostSendExtensionInterface) { + $this->postSendExtensions[] = $extension; + + $extensionValid = true; + } + + if (false == $extensionValid) { + throw new \LogicException(sprintf('Invalid extension given %s', $extension::class)); + } + }); + } + + public function onPreSendEvent(PreSend $context): void + { + foreach ($this->preSendEventExtensions as $extension) { + $extension->onPreSendEvent($context); + } + } + + public function onPreSendCommand(PreSend $context): void + { + foreach ($this->preSendCommandExtensions as $extension) { + $extension->onPreSendCommand($context); + } + } + + public function onDriverPreSend(DriverPreSend $context): void + { + foreach ($this->driverPreSendExtensions as $extension) { + $extension->onDriverPreSend($context); + } + } + + public function onPostSend(PostSend $context): void + { + foreach ($this->postSendExtensions as $extension) { + $extension->onPostSend($context); + } + } +} diff --git a/pkg/enqueue/Client/CommandSubscriberInterface.php b/pkg/enqueue/Client/CommandSubscriberInterface.php new file mode 100644 index 000000000..d7b06daaf --- /dev/null +++ b/pkg/enqueue/Client/CommandSubscriberInterface.php @@ -0,0 +1,60 @@ + 'aSubscribedCommand', + * 'processor' => 'aProcessorName', + * 'queue' => 'a_client_queue_name', + * 'prefix_queue' => true, + * 'exclusive' => true, + * ] + * + * or + * + * [ + * [ + * 'command' => 'aSubscribedCommand', + * 'processor' => 'aProcessorName', + * 'queue' => 'a_client_queue_name', + * 'prefix_queue' => true, + * 'exclusive' => true, + * ], + * [ + * 'command' => 'aSubscribedCommand', + * 'processor' => 'aProcessorName', + * 'queue' => 'a_client_queue_name', + * 'prefix_queue' => true, + * 'exclusive' => true, + * ] + * ] + * + * queue, processor, prefix_queue, and exclusive are optional. + * It is possible to pass other options, they could be accessible on a route instance through options. + * + * Note: If you set "prefix_queue" to true then the "queue" is used as is and therefor the driver is not used to create a transport queue name. + * + * @return string|array + * + * @phpstan-return string|CommandConfig|array + */ + public static function getSubscribedCommand(); +} diff --git a/pkg/enqueue/Client/Config.php b/pkg/enqueue/Client/Config.php index 66fc07596..8210dff68 100644 --- a/pkg/enqueue/Client/Config.php +++ b/pkg/enqueue/Client/Config.php @@ -4,10 +4,13 @@ class Config { - const PARAMETER_TOPIC_NAME = 'enqueue.topic_name'; - const PARAMETER_PROCESSOR_NAME = 'enqueue.processor_name'; - const PARAMETER_PROCESSOR_QUEUE_NAME = 'enqueue.processor_queue_name'; - const DEFAULT_PROCESSOR_QUEUE_NAME = 'default'; + public const TOPIC = 'enqueue.topic'; + public const COMMAND = 'enqueue.command'; + public const PROCESSOR = 'enqueue.processor'; + public const EXPIRE = 'enqueue.expire'; + public const PRIORITY = 'enqueue.priority'; + public const DELAY = 'enqueue.delay'; + public const CONTENT_TYPE = 'enqueue.content_type'; /** * @var string @@ -17,27 +20,32 @@ class Config /** * @var string */ - private $appName; + private $separator; /** * @var string */ - private $routerTopicName; + private $app; /** * @var string */ - private $routerQueueName; + private $routerTopic; /** * @var string */ - private $defaultProcessorQueueName; + private $routerQueue; /** * @var string */ - private $routerProcessorName; + private $defaultQueue; + + /** + * @var string + */ + private $routerProcessor; /** * @var array @@ -45,113 +53,126 @@ class Config private $transportConfig; /** - * @param string $prefix - * @param string $appName - * @param string $routerTopicName - * @param string $routerQueueName - * @param string $defaultProcessorQueueName - * @param string $routerProcessorName - * @param array $transportConfig + * @var array */ - public function __construct($prefix, $appName, $routerTopicName, $routerQueueName, $defaultProcessorQueueName, $routerProcessorName, array $transportConfig = []) - { - $this->prefix = $prefix; - $this->appName = $appName; - $this->routerTopicName = $routerTopicName; - $this->routerQueueName = $routerQueueName; - $this->defaultProcessorQueueName = $defaultProcessorQueueName; - $this->routerProcessorName = $routerProcessorName; + private $driverConfig; + + public function __construct( + string $prefix, + string $separator, + string $app, + string $routerTopic, + string $routerQueue, + string $defaultQueue, + string $routerProcessor, + array $transportConfig, + array $driverConfig, + ) { + $this->prefix = trim($prefix); + $this->app = trim($app); + + $this->routerTopic = trim($routerTopic); + if (empty($this->routerTopic)) { + throw new \InvalidArgumentException('Router topic is empty.'); + } + + $this->routerQueue = trim($routerQueue); + if (empty($this->routerQueue)) { + throw new \InvalidArgumentException('Router queue is empty.'); + } + + $this->defaultQueue = trim($defaultQueue); + if (empty($this->defaultQueue)) { + throw new \InvalidArgumentException('Default processor queue name is empty.'); + } + + $this->routerProcessor = trim($routerProcessor); + if (empty($this->routerProcessor)) { + throw new \InvalidArgumentException('Router processor name is empty.'); + } + $this->transportConfig = $transportConfig; + $this->driverConfig = $driverConfig; + + $this->separator = $separator; } - /** - * @return string - */ - public function getRouterTopicName() + public function getPrefix(): string { - return $this->routerTopicName; + return $this->prefix; } - /** - * @return string - */ - public function getRouterQueueName() + public function getSeparator(): string { - return $this->routerQueueName; + return $this->separator; } - /** - * @return string - */ - public function getDefaultProcessorQueueName() + public function getApp(): string { - return $this->defaultProcessorQueueName; + return $this->app; } - /** - * @return string - */ - public function getRouterProcessorName() + public function getRouterTopic(): string { - return $this->routerProcessorName; + return $this->routerTopic; } - /** - * @param string $name - * - * @return string - */ - public function createTransportRouterTopicName($name) + public function getRouterQueue(): string { - return trim(strtolower(trim($this->prefix).'.'.trim($name)), '.'); + return $this->routerQueue; } - /** - * @param string $name - * - * @return string - */ - public function createTransportQueueName($name) + public function getDefaultQueue(): string { - return trim(strtolower(trim($this->prefix).'.'.trim($this->appName).'.'.trim($name)), '.'); + return $this->defaultQueue; } - /** - * @param string $name - * @param mixed|null $default - * - * @return array - */ - public function getTransportOption($name, $default = null) + public function getRouterProcessor(): string + { + return $this->routerProcessor; + } + + public function getTransportOption(string $name, $default = null) { return array_key_exists($name, $this->transportConfig) ? $this->transportConfig[$name] : $default; } - /** - * @param string|null $prefix - * @param string|null $appName - * @param string|null $routerTopicName - * @param string|null $routerQueueName - * @param string|null $defaultProcessorQueueName - * @param string|null $routerProcessorName - * - * @return static - */ + public function getTransportOptions(): array + { + return $this->transportConfig; + } + + public function getDriverOption(string $name, $default = null) + { + return array_key_exists($name, $this->driverConfig) ? $this->driverConfig[$name] : $default; + } + + public function getDriverOptions(): array + { + return $this->driverConfig; + } + public static function create( - $prefix = null, - $appName = null, - $routerTopicName = null, - $routerQueueName = null, - $defaultProcessorQueueName = null, - $routerProcessorName = null - ) { - return new static( + ?string $prefix = null, + ?string $separator = null, + ?string $app = null, + ?string $routerTopic = null, + ?string $routerQueue = null, + ?string $defaultQueue = null, + ?string $routerProcessor = null, + array $transportConfig = [], + array $driverConfig = [], + ): self { + return new self( $prefix ?: '', - $appName ?: '', - $routerTopicName ?: 'router', - $routerQueueName ?: 'default', - $defaultProcessorQueueName ?: 'default', - $routerProcessorName ?: 'router' + $separator ?: '.', + $app ?: '', + $routerTopic ?: 'router', + $routerQueue ?: 'default', + $defaultQueue ?: 'default', + $routerProcessor ?: 'router', + $transportConfig, + $driverConfig ); } } diff --git a/pkg/enqueue/Client/ConsumptionExtension/DelayRedeliveredMessageExtension.php b/pkg/enqueue/Client/ConsumptionExtension/DelayRedeliveredMessageExtension.php index 7f1b49d79..475e2cf5b 100644 --- a/pkg/enqueue/Client/ConsumptionExtension/DelayRedeliveredMessageExtension.php +++ b/pkg/enqueue/Client/ConsumptionExtension/DelayRedeliveredMessageExtension.php @@ -3,16 +3,13 @@ namespace Enqueue\Client\ConsumptionExtension; use Enqueue\Client\DriverInterface; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\MessageReceivedExtensionInterface; use Enqueue\Consumption\Result; -class DelayRedeliveredMessageExtension implements ExtensionInterface +class DelayRedeliveredMessageExtension implements MessageReceivedExtensionInterface { - use EmptyExtensionTrait; - - const PROPERTY_REDELIVER_COUNT = 'enqueue.redelivery_count'; + public const PROPERTY_REDELIVER_COUNT = 'enqueue.redelivery_count'; /** * @var DriverInterface @@ -27,8 +24,7 @@ class DelayRedeliveredMessageExtension implements ExtensionInterface private $delay; /** - * @param DriverInterface $driver - * @param int $delay The number of seconds the message should be delayed + * @param int $delay The number of seconds the message should be delayed */ public function __construct(DriverInterface $driver, $delay) { @@ -36,15 +32,15 @@ public function __construct(DriverInterface $driver, $delay) $this->delay = $delay; } - /** - * {@inheritdoc} - */ - public function onPreReceived(Context $context) + public function onMessageReceived(MessageReceived $context): void { - $message = $context->getPsrMessage(); + $message = $context->getMessage(); if (false == $message->isRedelivered()) { return; } + if (false != $context->getResult()) { + return; + } $delayedMessage = $this->driver->createClientMessage($message); @@ -57,7 +53,7 @@ public function onPreReceived(Context $context) $this->driver->sendToProcessor($delayedMessage); $context->getLogger()->debug('[DelayRedeliveredMessageExtension] Send delayed message'); - $context->setResult(Result::REJECT); + $context->setResult(Result::reject('A new copy of the message was sent with a delay. The original message is rejected')); $context->getLogger()->debug( '[DelayRedeliveredMessageExtension] '. 'Reject redelivered original message by setting reject status to context.' diff --git a/pkg/enqueue/Client/ConsumptionExtension/ExclusiveCommandExtension.php b/pkg/enqueue/Client/ConsumptionExtension/ExclusiveCommandExtension.php new file mode 100644 index 000000000..7ab88ae0f --- /dev/null +++ b/pkg/enqueue/Client/ConsumptionExtension/ExclusiveCommandExtension.php @@ -0,0 +1,77 @@ +driver = $driver; + } + + public function onMessageReceived(MessageReceived $context): void + { + $message = $context->getMessage(); + if ($message->getProperty(Config::TOPIC)) { + return; + } + if ($message->getProperty(Config::COMMAND)) { + return; + } + if ($message->getProperty(Config::PROCESSOR)) { + return; + } + + if (null === $this->queueToRouteMap) { + $this->queueToRouteMap = $this->buildMap(); + } + + $queue = $context->getConsumer()->getQueue(); + if (array_key_exists($queue->getQueueName(), $this->queueToRouteMap)) { + $context->getLogger()->debug('[ExclusiveCommandExtension] This is a exclusive command queue and client\'s properties are not set. Setting them'); + + $route = $this->queueToRouteMap[$queue->getQueueName()]; + $message->setProperty(Config::PROCESSOR, $route->getProcessor()); + $message->setProperty(Config::COMMAND, $route->getSource()); + } + } + + private function buildMap(): array + { + $map = []; + foreach ($this->driver->getRouteCollection()->all() as $route) { + if (false == $route->isCommand()) { + continue; + } + + if (false == $route->isProcessorExclusive()) { + continue; + } + + $queueName = $this->driver->createRouteQueue($route)->getQueueName(); + if (array_key_exists($queueName, $map)) { + throw new \LogicException('The queue name has been already bound by another exclusive command processor'); + } + + $map[$queueName] = $route; + } + + return $map; + } +} diff --git a/pkg/enqueue/Client/ConsumptionExtension/FlushSpoolProducerExtension.php b/pkg/enqueue/Client/ConsumptionExtension/FlushSpoolProducerExtension.php new file mode 100644 index 000000000..6682cad8e --- /dev/null +++ b/pkg/enqueue/Client/ConsumptionExtension/FlushSpoolProducerExtension.php @@ -0,0 +1,32 @@ +producer = $producer; + } + + public function onPostMessageReceived(PostMessageReceived $context): void + { + $this->producer->flush(); + } + + public function onEnd(End $context): void + { + $this->producer->flush(); + } +} diff --git a/pkg/enqueue/Client/ConsumptionExtension/LogExtension.php b/pkg/enqueue/Client/ConsumptionExtension/LogExtension.php new file mode 100644 index 000000000..693be2035 --- /dev/null +++ b/pkg/enqueue/Client/ConsumptionExtension/LogExtension.php @@ -0,0 +1,69 @@ +getResult(); + $message = $context->getMessage(); + + $logLevel = Result::REJECT == ((string) $result) ? LogLevel::ERROR : LogLevel::INFO; + + if ($command = $message->getProperty(Config::COMMAND)) { + $reason = ''; + $logMessage = "[client] Processed {command}\t{body}\t{result}"; + if ($result instanceof Result && $result->getReason()) { + $reason = $result->getReason(); + + $logMessage .= ' {reason}'; + } + + $context->getLogger()->log($logLevel, $logMessage, [ + 'result' => str_replace('enqueue.', '', $result), + 'reason' => $reason, + 'command' => $command, + 'queueName' => $context->getConsumer()->getQueue()->getQueueName(), + 'body' => Stringify::that($message->getBody()), + 'properties' => Stringify::that($message->getProperties()), + 'headers' => Stringify::that($message->getHeaders()), + ]); + + return; + } + + $topic = $message->getProperty(Config::TOPIC); + $processor = $message->getProperty(Config::PROCESSOR); + if ($topic && $processor) { + $reason = ''; + $logMessage = "[client] Processed {topic} -> {processor}\t{body}\t{result}"; + if ($result instanceof Result && $result->getReason()) { + $reason = $result->getReason(); + + $logMessage .= ' {reason}'; + } + + $context->getLogger()->log($logLevel, $logMessage, [ + 'result' => str_replace('enqueue.', '', $result), + 'reason' => $reason, + 'topic' => $topic, + 'processor' => $processor, + 'queueName' => $context->getConsumer()->getQueue()->getQueueName(), + 'body' => Stringify::that($message->getBody()), + 'properties' => Stringify::that($message->getProperties()), + 'headers' => Stringify::that($message->getHeaders()), + ]); + + return; + } + + parent::onPostMessageReceived($context); + } +} diff --git a/pkg/enqueue/Client/ConsumptionExtension/SetRouterPropertiesExtension.php b/pkg/enqueue/Client/ConsumptionExtension/SetRouterPropertiesExtension.php index 565d887bf..0d2278349 100644 --- a/pkg/enqueue/Client/ConsumptionExtension/SetRouterPropertiesExtension.php +++ b/pkg/enqueue/Client/ConsumptionExtension/SetRouterPropertiesExtension.php @@ -4,39 +4,43 @@ use Enqueue\Client\Config; use Enqueue\Client\DriverInterface; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\MessageReceivedExtensionInterface; -class SetRouterPropertiesExtension implements ExtensionInterface +class SetRouterPropertiesExtension implements MessageReceivedExtensionInterface { - use EmptyExtensionTrait; - /** * @var DriverInterface */ private $driver; - /** - * @param DriverInterface $driver - */ public function __construct(DriverInterface $driver) { $this->driver = $driver; } - /** - * {@inheritdoc} - */ - public function onPreReceived(Context $context) + public function onMessageReceived(MessageReceived $context): void { - $message = $context->getPsrMessage(); - if ($message->getProperty(Config::PARAMETER_PROCESSOR_NAME)) { + $message = $context->getMessage(); + if (false == $message->getProperty(Config::TOPIC)) { + return; + } + if ($message->getProperty(Config::PROCESSOR)) { + return; + } + + $config = $this->driver->getConfig(); + $queue = $this->driver->createQueue($config->getRouterQueue()); + if ($context->getConsumer()->getQueue()->getQueueName() != $queue->getQueueName()) { return; } // RouterProcessor is our default message processor when that header is not set - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, $this->driver->getConfig()->getRouterProcessorName()); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, $this->driver->getConfig()->getRouterQueueName()); + $message->setProperty(Config::PROCESSOR, $config->getRouterProcessor()); + + $context->getLogger()->debug( + '[SetRouterPropertiesExtension] '. + sprintf('Set router processor "%s"', $config->getRouterProcessor()) + ); } } diff --git a/pkg/enqueue/Client/ConsumptionExtension/SetupBrokerExtension.php b/pkg/enqueue/Client/ConsumptionExtension/SetupBrokerExtension.php index 8b6aecbc1..44d610fb9 100644 --- a/pkg/enqueue/Client/ConsumptionExtension/SetupBrokerExtension.php +++ b/pkg/enqueue/Client/ConsumptionExtension/SetupBrokerExtension.php @@ -3,14 +3,11 @@ namespace Enqueue\Client\ConsumptionExtension; use Enqueue\Client\DriverInterface; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\Start; +use Enqueue\Consumption\StartExtensionInterface; -class SetupBrokerExtension implements ExtensionInterface +class SetupBrokerExtension implements StartExtensionInterface { - use EmptyExtensionTrait; - /** * @var DriverInterface */ @@ -21,19 +18,13 @@ class SetupBrokerExtension implements ExtensionInterface */ private $isDone; - /** - * @param DriverInterface $driver - */ public function __construct(DriverInterface $driver) { $this->driver = $driver; $this->isDone = false; } - /** - * {@inheritdoc} - */ - public function onStart(Context $context) + public function onStart(Start $context): void { if (false == $this->isDone) { $this->isDone = true; diff --git a/pkg/enqueue/Client/DelegateProcessor.php b/pkg/enqueue/Client/DelegateProcessor.php index 910a9e504..7582c52dc 100644 --- a/pkg/enqueue/Client/DelegateProcessor.php +++ b/pkg/enqueue/Client/DelegateProcessor.php @@ -2,9 +2,10 @@ namespace Enqueue\Client; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message as PsrMessage; -use Enqueue\Psr\Processor; +use Enqueue\ProcessorRegistryInterface; +use Interop\Queue\Context; +use Interop\Queue\Message as InteropMessage; +use Interop\Queue\Processor; class DelegateProcessor implements Processor { @@ -13,25 +14,19 @@ class DelegateProcessor implements Processor */ private $registry; - /** - * @param ProcessorRegistryInterface $registry - */ public function __construct(ProcessorRegistryInterface $registry) { $this->registry = $registry; } /** - * {@inheritdoc} + * @return string|object */ - public function process(PsrMessage $message, Context $context) + public function process(InteropMessage $message, Context $context) { - $processorName = $message->getProperty(Config::PARAMETER_PROCESSOR_NAME); + $processorName = $message->getProperty(Config::PROCESSOR); if (false == $processorName) { - throw new \LogicException(sprintf( - 'Got message without required parameter: "%s"', - Config::PARAMETER_PROCESSOR_NAME - )); + throw new \LogicException(sprintf('Got message without required parameter: "%s"', Config::PROCESSOR)); } return $this->registry->get($processorName)->process($message, $context); diff --git a/pkg/enqueue/Client/Driver/AmqpDriver.php b/pkg/enqueue/Client/Driver/AmqpDriver.php new file mode 100644 index 000000000..1def3fb23 --- /dev/null +++ b/pkg/enqueue/Client/Driver/AmqpDriver.php @@ -0,0 +1,132 @@ +setDeliveryMode(AmqpMessage::DELIVERY_MODE_PERSISTENT); + $transportMessage->setContentType($clientMessage->getContentType()); + + if ($clientMessage->getExpire()) { + $transportMessage->setExpiration($clientMessage->getExpire() * 1000); + } + + $priorityMap = $this->getPriorityMap(); + if ($priority = $clientMessage->getPriority()) { + if (false == array_key_exists($priority, $priorityMap)) { + throw new \InvalidArgumentException(sprintf('Cant convert client priority "%s" to transport one. Could be one of "%s"', $priority, implode('", "', array_keys($priorityMap)))); + } + + $transportMessage->setPriority($priorityMap[$priority]); + } + + return $transportMessage; + } + + public function setupBroker(?LoggerInterface $logger = null): void + { + $logger = $logger ?: new NullLogger(); + $log = function ($text, ...$args) use ($logger) { + $logger->debug(sprintf('[AmqpDriver] '.$text, ...$args)); + }; + + // setup router + $routerTopic = $this->createRouterTopic(); + $log('Declare router exchange: %s', $routerTopic->getTopicName()); + $this->getContext()->declareTopic($routerTopic); + + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + $log('Declare router queue: %s', $routerQueue->getQueueName()); + $this->getContext()->declareQueue($routerQueue); + + $log('Bind router queue to exchange: %s -> %s', $routerQueue->getQueueName(), $routerTopic->getTopicName()); + $this->getContext()->bind(new AmqpBind($routerTopic, $routerQueue, $routerQueue->getQueueName())); + + // setup queues + $declaredQueues = []; + foreach ($this->getRouteCollection()->all() as $route) { + /** @var AmqpQueue $queue */ + $queue = $this->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $declaredQueues)) { + continue; + } + + $log('Declare processor queue: %s', $queue->getQueueName()); + $this->getContext()->declareQueue($queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + } + + /** + * @return AmqpTopic + */ + protected function createRouterTopic(): Destination + { + $topic = $this->doCreateTopic( + $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true) + ); + $topic->setType(AmqpTopic::TYPE_FANOUT); + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + + return $topic; + } + + /** + * @return AmqpQueue + */ + protected function doCreateQueue(string $transportQueueName): InteropQueue + { + /** @var AmqpQueue $queue */ + $queue = parent::doCreateQueue($transportQueueName); + $queue->addFlag(AmqpQueue::FLAG_DURABLE); + + return $queue; + } + + /** + * @param AmqpProducer $producer + * @param AmqpTopic $topic + * @param AmqpMessage $transportMessage + */ + protected function doSendToRouter(InteropProducer $producer, Destination $topic, InteropMessage $transportMessage): void + { + // We should not handle priority, expiration, and delay at this stage. + // The router will take care of it while re-sending the message to the final destinations. + $transportMessage->setPriority(null); + $transportMessage->setExpiration(null); + + $producer->send($topic, $transportMessage); + } +} diff --git a/pkg/enqueue/Client/Driver/DbalDriver.php b/pkg/enqueue/Client/Driver/DbalDriver.php new file mode 100644 index 000000000..34875eff7 --- /dev/null +++ b/pkg/enqueue/Client/Driver/DbalDriver.php @@ -0,0 +1,29 @@ +debug(sprintf('[DbalDriver] '.$text, ...$args)); + }; + + $log('Creating database table: "%s"', $this->getContext()->getTableName()); + $this->getContext()->createDataBaseTable(); + } +} diff --git a/pkg/enqueue/Client/Driver/FsDriver.php b/pkg/enqueue/Client/Driver/FsDriver.php new file mode 100644 index 000000000..f578b172d --- /dev/null +++ b/pkg/enqueue/Client/Driver/FsDriver.php @@ -0,0 +1,49 @@ +debug(sprintf('[FsDriver] '.$text, ...$args)); + }; + + // setup router + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + + $log('Declare router queue "%s" file: %s', $routerQueue->getQueueName(), $routerQueue->getFileInfo()); + $this->getContext()->declareDestination($routerQueue); + + // setup queues + $declaredQueues = []; + foreach ($this->getRouteCollection()->all() as $route) { + /** @var FsDestination $queue */ + $queue = $this->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $declaredQueues)) { + continue; + } + + $log('Declare processor queue "%s" file: %s', $queue->getQueueName(), $queue->getFileInfo()); + $this->getContext()->declareDestination($queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + } +} diff --git a/pkg/enqueue/Client/Driver/GenericDriver.php b/pkg/enqueue/Client/Driver/GenericDriver.php new file mode 100644 index 000000000..d509677df --- /dev/null +++ b/pkg/enqueue/Client/Driver/GenericDriver.php @@ -0,0 +1,278 @@ +context = $context; + $this->config = $config; + $this->routeCollection = $routeCollection; + } + + public function sendToRouter(Message $message): DriverSendResult + { + if ($message->getProperty(Config::COMMAND)) { + throw new \LogicException('Command must not be send to router but go directly to its processor.'); + } + if (false == $message->getProperty(Config::TOPIC)) { + throw new \LogicException('Topic name parameter is required but is not set'); + } + + $topic = $this->createRouterTopic(); + $transportMessage = $this->createTransportMessage($message); + $producer = $this->getContext()->createProducer(); + + $this->doSendToRouter($producer, $topic, $transportMessage); + + return new DriverSendResult($topic, $transportMessage); + } + + public function sendToProcessor(Message $message): DriverSendResult + { + $topic = $message->getProperty(Config::TOPIC); + $command = $message->getProperty(Config::COMMAND); + + /** @var InteropQueue $queue */ + $queue = null; + $routerProcessor = $this->config->getRouterProcessor(); + $processor = $message->getProperty(Config::PROCESSOR); + if ($topic && $processor && $processor !== $routerProcessor) { + $route = $this->routeCollection->topicAndProcessor($topic, $processor); + if (false == $route) { + throw new \LogicException(sprintf('There is no route for topic "%s" and processor "%s"', $topic, $processor)); + } + + $message->setProperty(Config::PROCESSOR, $route->getProcessor()); + $queue = $this->createRouteQueue($route); + } elseif ($topic && (false == $processor || $processor === $routerProcessor)) { + $message->setProperty(Config::PROCESSOR, $routerProcessor); + + $queue = $this->createQueue($this->config->getRouterQueue()); + } elseif ($command) { + $route = $this->routeCollection->command($command); + if (false == $route) { + throw new \LogicException(sprintf('There is no route for command "%s".', $command)); + } + + $message->setProperty(Config::PROCESSOR, $route->getProcessor()); + $queue = $this->createRouteQueue($route); + } else { + throw new \LogicException('Either topic or command parameter must be set.'); + } + + $transportMessage = $this->createTransportMessage($message); + + $producer = $this->context->createProducer(); + + if (null !== $delay = $transportMessage->getProperty(Config::DELAY)) { + $producer->setDeliveryDelay($delay * 1000); + } + + if (null !== $expire = $transportMessage->getProperty(Config::EXPIRE)) { + $producer->setTimeToLive($expire * 1000); + } + + if (null !== $priority = $transportMessage->getProperty(Config::PRIORITY)) { + $priorityMap = $this->getPriorityMap(); + + $producer->setPriority($priorityMap[$priority]); + } + + $this->doSendToProcessor($producer, $queue, $transportMessage); + + return new DriverSendResult($queue, $transportMessage); + } + + public function setupBroker(?LoggerInterface $logger = null): void + { + } + + public function createQueue(string $clientQueueName, bool $prefix = true): InteropQueue + { + $transportName = $this->createTransportQueueName($clientQueueName, $prefix); + + return $this->doCreateQueue($transportName); + } + + public function createRouteQueue(Route $route): InteropQueue + { + $transportName = $this->createTransportQueueName( + $route->getQueue() ?: $this->config->getDefaultQueue(), + $route->isPrefixQueue() + ); + + return $this->doCreateQueue($transportName); + } + + public function createTransportMessage(Message $clientMessage): InteropMessage + { + $headers = $clientMessage->getHeaders(); + $properties = $clientMessage->getProperties(); + + $transportMessage = $this->context->createMessage(); + $transportMessage->setBody($clientMessage->getBody()); + $transportMessage->setHeaders($headers); + $transportMessage->setProperties($properties); + $transportMessage->setMessageId($clientMessage->getMessageId()); + $transportMessage->setTimestamp($clientMessage->getTimestamp()); + $transportMessage->setReplyTo($clientMessage->getReplyTo()); + $transportMessage->setCorrelationId($clientMessage->getCorrelationId()); + + if ($contentType = $clientMessage->getContentType()) { + $transportMessage->setProperty(Config::CONTENT_TYPE, $contentType); + } + + if ($priority = $clientMessage->getPriority()) { + $transportMessage->setProperty(Config::PRIORITY, $priority); + } + + if ($expire = $clientMessage->getExpire()) { + $transportMessage->setProperty(Config::EXPIRE, $expire); + } + + if ($delay = $clientMessage->getDelay()) { + $transportMessage->setProperty(Config::DELAY, $delay); + } + + return $transportMessage; + } + + public function createClientMessage(InteropMessage $transportMessage): Message + { + $clientMessage = new Message(); + + $clientMessage->setBody($transportMessage->getBody()); + $clientMessage->setHeaders($transportMessage->getHeaders()); + $clientMessage->setProperties($transportMessage->getProperties()); + $clientMessage->setMessageId($transportMessage->getMessageId()); + $clientMessage->setTimestamp($transportMessage->getTimestamp()); + $clientMessage->setReplyTo($transportMessage->getReplyTo()); + $clientMessage->setCorrelationId($transportMessage->getCorrelationId()); + + if ($contentType = $transportMessage->getProperty(Config::CONTENT_TYPE)) { + $clientMessage->setContentType($contentType); + } + + if ($priority = $transportMessage->getProperty(Config::PRIORITY)) { + $clientMessage->setPriority($priority); + } + + if ($delay = $transportMessage->getProperty(Config::DELAY)) { + $clientMessage->setDelay((int) $delay); + } + + if ($expire = $transportMessage->getProperty(Config::EXPIRE)) { + $clientMessage->setExpire((int) $expire); + } + + return $clientMessage; + } + + public function getConfig(): Config + { + return $this->config; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getRouteCollection(): RouteCollection + { + return $this->routeCollection; + } + + protected function doSendToRouter(InteropProducer $producer, Destination $topic, InteropMessage $transportMessage): void + { + $producer->send($topic, $transportMessage); + } + + protected function doSendToProcessor(InteropProducer $producer, InteropQueue $queue, InteropMessage $transportMessage): void + { + $producer->send($queue, $transportMessage); + } + + protected function createRouterTopic(): Destination + { + return $this->createQueue($this->getConfig()->getRouterQueue()); + } + + protected function createTransportRouterTopicName(string $name, bool $prefix): string + { + $clientPrefix = $prefix ? $this->config->getPrefix() : ''; + + return strtolower(implode($this->config->getSeparator(), array_filter([$clientPrefix, $name]))); + } + + protected function createTransportQueueName(string $name, bool $prefix): string + { + $clientPrefix = $prefix ? $this->config->getPrefix() : ''; + $clientAppName = $prefix ? $this->config->getApp() : ''; + + return strtolower(implode($this->config->getSeparator(), array_filter([$clientPrefix, $clientAppName, $name]))); + } + + protected function doCreateQueue(string $transportQueueName): InteropQueue + { + return $this->context->createQueue($transportQueueName); + } + + protected function doCreateTopic(string $transportTopicName): InteropTopic + { + return $this->context->createTopic($transportTopicName); + } + + /** + * [client message priority => transport message priority]. + * + * @return int[] + */ + protected function getPriorityMap(): array + { + return [ + MessagePriority::VERY_LOW => 0, + MessagePriority::LOW => 1, + MessagePriority::NORMAL => 2, + MessagePriority::HIGH => 3, + MessagePriority::VERY_HIGH => 4, + ]; + } +} diff --git a/pkg/enqueue/Client/Driver/GpsDriver.php b/pkg/enqueue/Client/Driver/GpsDriver.php new file mode 100644 index 000000000..32d14f721 --- /dev/null +++ b/pkg/enqueue/Client/Driver/GpsDriver.php @@ -0,0 +1,64 @@ +debug(sprintf('[GpsDriver] '.$text, ...$args)); + }; + + // setup router + $routerTopic = $this->createRouterTopic(); + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + + $log('Subscribe router topic to queue: %s -> %s', $routerTopic->getTopicName(), $routerQueue->getQueueName()); + $this->getContext()->subscribe($routerTopic, $routerQueue); + + // setup queues + $declaredQueues = []; + foreach ($this->getRouteCollection()->all() as $route) { + /** @var GpsQueue $queue */ + $queue = $this->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $declaredQueues)) { + continue; + } + + $topic = $this->getContext()->createTopic($queue->getQueueName()); + + $log('Subscribe processor topic to queue: %s -> %s', $topic->getTopicName(), $queue->getQueueName()); + $this->getContext()->subscribe($topic, $queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + } + + /** + * @return GpsTopic + */ + protected function createRouterTopic(): Destination + { + return $this->doCreateTopic( + $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true) + ); + } +} diff --git a/pkg/enqueue/Client/Driver/MongodbDriver.php b/pkg/enqueue/Client/Driver/MongodbDriver.php new file mode 100644 index 000000000..1c9cff4bc --- /dev/null +++ b/pkg/enqueue/Client/Driver/MongodbDriver.php @@ -0,0 +1,30 @@ +debug(sprintf('[MongodbDriver] '.$text, ...$args)); + }; + + $contextConfig = $this->getContext()->getConfig(); + $log('Creating database and collection: "%s" "%s"', $contextConfig['dbname'], $contextConfig['collection_name']); + $this->getContext()->createCollection(); + } +} diff --git a/pkg/enqueue/Client/Driver/RabbitMqDriver.php b/pkg/enqueue/Client/Driver/RabbitMqDriver.php new file mode 100644 index 000000000..f215d555e --- /dev/null +++ b/pkg/enqueue/Client/Driver/RabbitMqDriver.php @@ -0,0 +1,20 @@ +setArguments(['x-max-priority' => 4]); + + return $queue; + } +} diff --git a/pkg/enqueue/Client/Driver/RabbitMqStompDriver.php b/pkg/enqueue/Client/Driver/RabbitMqStompDriver.php new file mode 100644 index 000000000..7af2db850 --- /dev/null +++ b/pkg/enqueue/Client/Driver/RabbitMqStompDriver.php @@ -0,0 +1,191 @@ +management = $management; + } + + /** + * @return StompMessage + */ + public function createTransportMessage(Message $message): InteropMessage + { + $transportMessage = parent::createTransportMessage($message); + + if ($message->getExpire()) { + $transportMessage->setHeader('expiration', (string) ($message->getExpire() * 1000)); + } + + if ($priority = $message->getPriority()) { + $priorityMap = $this->getPriorityMap(); + + if (false == array_key_exists($priority, $priorityMap)) { + throw new \LogicException(sprintf('Cant convert client priority to transport: "%s"', $priority)); + } + + $transportMessage->setHeader('priority', $priorityMap[$priority]); + } + + if ($message->getDelay()) { + if (false == $this->getConfig()->getTransportOption('delay_plugin_installed', false)) { + throw new \LogicException('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); + } + + $transportMessage->setHeader('x-delay', (string) ($message->getDelay() * 1000)); + } + + return $transportMessage; + } + + public function setupBroker(?LoggerInterface $logger = null): void + { + $logger = $logger ?: new NullLogger(); + $log = function ($text, ...$args) use ($logger) { + $logger->debug(sprintf('[RabbitMqStompDriver] '.$text, ...$args)); + }; + + if (false == $this->getConfig()->getTransportOption('management_plugin_installed', false)) { + $log('Could not setup broker. The option `management_plugin_installed` is not enabled. Please enable that option and install rabbit management plugin'); + + return; + } + + // setup router + $routerExchange = $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true); + $log('Declare router exchange: %s', $routerExchange); + $this->management->declareExchange($routerExchange, [ + 'type' => 'fanout', + 'durable' => true, + 'auto_delete' => false, + ]); + + $routerQueue = $this->createTransportQueueName($this->getConfig()->getRouterQueue(), true); + $log('Declare router queue: %s', $routerQueue); + $this->management->declareQueue($routerQueue, [ + 'auto_delete' => false, + 'durable' => true, + 'arguments' => [ + 'x-max-priority' => 4, + ], + ]); + + $log('Bind router queue to exchange: %s -> %s', $routerQueue, $routerExchange); + $this->management->bind($routerExchange, $routerQueue, $routerQueue); + + // setup queues + foreach ($this->getRouteCollection()->all() as $route) { + $queue = $this->createRouteQueue($route); + + $log('Declare processor queue: %s', $queue->getStompName()); + $this->management->declareQueue($queue->getStompName(), [ + 'auto_delete' => false, + 'durable' => true, + 'arguments' => [ + 'x-max-priority' => 4, + ], + ]); + } + + // setup delay exchanges + if ($this->getConfig()->getTransportOption('delay_plugin_installed', false)) { + foreach ($this->getRouteCollection()->all() as $route) { + $queue = $this->createRouteQueue($route); + $delayExchange = $queue->getStompName().'.delayed'; + + $log('Declare delay exchange: %s', $delayExchange); + $this->management->declareExchange($delayExchange, [ + 'type' => 'x-delayed-message', + 'durable' => true, + 'auto_delete' => false, + 'arguments' => [ + 'x-delayed-type' => 'direct', + ], + ]); + + $log('Bind processor queue to delay exchange: %s -> %s', $queue->getStompName(), $delayExchange); + $this->management->bind($delayExchange, $queue->getStompName(), $queue->getStompName()); + } + } else { + $log('Delay exchange and bindings are not setup. if you\'d like to use delays please install delay rabbitmq plugin and set delay_plugin_installed option to true'); + } + } + + /** + * @return StompDestination + */ + protected function doCreateQueue(string $transportQueueName): InteropQueue + { + $queue = parent::doCreateQueue($transportQueueName); + $queue->setHeader('x-max-priority', 4); + + return $queue; + } + + /** + * @param StompProducer $producer + * @param StompDestination $topic + * @param StompMessage $transportMessage + */ + protected function doSendToRouter(InteropProducer $producer, Destination $topic, InteropMessage $transportMessage): void + { + // We should not handle priority, expiration, and delay at this stage. + // The router will take care of it while re-sending the message to the final destinations. + $transportMessage->setHeader('expiration', null); + $transportMessage->setHeader('priority', null); + $transportMessage->setHeader('x-delay', null); + + $producer->send($topic, $transportMessage); + } + + /** + * @param StompProducer $producer + * @param StompDestination $destination + * @param StompMessage $transportMessage + */ + protected function doSendToProcessor(InteropProducer $producer, InteropQueue $destination, InteropMessage $transportMessage): void + { + if ($delay = $transportMessage->getProperty(Config::DELAY)) { + $producer->setDeliveryDelay(null); + $destination = $this->createDelayedTopic($destination); + } + + $producer->send($destination, $transportMessage); + } + + private function createDelayedTopic(StompDestination $queue): StompDestination + { + // in order to use delay feature make sure the rabbitmq_delayed_message_exchange plugin is installed. + $destination = $this->getContext()->createTopic($queue->getStompName().'.delayed'); + $destination->setType(StompDestination::TYPE_EXCHANGE); + $destination->setDurable(true); + $destination->setAutoDelete(false); + $destination->setRoutingKey($queue->getStompName()); + + return $destination; + } +} diff --git a/pkg/enqueue/Client/Driver/RdKafkaDriver.php b/pkg/enqueue/Client/Driver/RdKafkaDriver.php new file mode 100644 index 000000000..2609e3f91 --- /dev/null +++ b/pkg/enqueue/Client/Driver/RdKafkaDriver.php @@ -0,0 +1,48 @@ +debug('[RdKafkaDriver] setup broker'); + $log = function ($text, ...$args) use ($logger) { + $logger->debug(sprintf('[RdKafkaDriver] '.$text, ...$args)); + }; + + // setup router + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + $log('Create router queue: %s', $routerQueue->getQueueName()); + $this->getContext()->createConsumer($routerQueue); + + // setup queues + $declaredQueues = []; + foreach ($this->getRouteCollection()->all() as $route) { + /** @var RdKafkaTopic $queue */ + $queue = $this->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $declaredQueues)) { + continue; + } + + $log('Create processor queue: %s', $queue->getQueueName()); + $this->getContext()->createConsumer($queue); + } + } +} diff --git a/pkg/enqueue/Client/Driver/RedisDriver.php b/pkg/enqueue/Client/Driver/RedisDriver.php new file mode 100644 index 000000000..493cb7c96 --- /dev/null +++ b/pkg/enqueue/Client/Driver/RedisDriver.php @@ -0,0 +1,20 @@ +debug(sprintf('[SqsQsDriver] '.$text, ...$args)); + }; + + // setup router + $routerTopic = $this->createRouterTopic(); + $log('Declare router topic: %s', $routerTopic->getTopicName()); + $this->getContext()->declareTopic($routerTopic); + + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + $log('Declare router queue: %s', $routerQueue->getQueueName()); + $this->getContext()->declareQueue($routerQueue); + + $log('Bind router queue to topic: %s -> %s', $routerQueue->getQueueName(), $routerTopic->getTopicName()); + $this->getContext()->bind($routerTopic, $routerQueue); + + // setup queues + $declaredQueues = []; + $declaredTopics = []; + foreach ($this->getRouteCollection()->all() as $route) { + $queue = $this->createRouteQueue($route); + if (false === array_key_exists($queue->getQueueName(), $declaredQueues)) { + $log('Declare processor queue: %s', $queue->getQueueName()); + $this->getContext()->declareQueue($queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + + if ($route->isCommand()) { + continue; + } + + $topic = $this->doCreateTopic($this->createTransportQueueName($route->getSource(), true)); + if (false === array_key_exists($topic->getTopicName(), $declaredTopics)) { + $log('Declare processor topic: %s', $topic->getTopicName()); + $this->getContext()->declareTopic($topic); + + $declaredTopics[$topic->getTopicName()] = true; + } + + $log('Bind processor queue to topic: %s -> %s', $queue->getQueueName(), $topic->getTopicName()); + $this->getContext()->bind($topic, $queue); + } + } + + protected function createRouterTopic(): Destination + { + return $this->doCreateTopic( + $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true) + ); + } + + protected function createTransportRouterTopicName(string $name, bool $prefix): string + { + $name = parent::createTransportRouterTopicName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } + + protected function createTransportQueueName(string $name, bool $prefix): string + { + $name = parent::createTransportQueueName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } +} diff --git a/pkg/enqueue/Client/Driver/SqsDriver.php b/pkg/enqueue/Client/Driver/SqsDriver.php new file mode 100644 index 000000000..49b696aae --- /dev/null +++ b/pkg/enqueue/Client/Driver/SqsDriver.php @@ -0,0 +1,62 @@ +debug(sprintf('[SqsDriver] '.$text, ...$args)); + }; + + // setup router + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + $log('Declare router queue: %s', $routerQueue->getQueueName()); + $this->getContext()->declareQueue($routerQueue); + + // setup queues + $declaredQueues = []; + foreach ($this->getRouteCollection()->all() as $route) { + /** @var SqsDestination $queue */ + $queue = $this->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $declaredQueues)) { + continue; + } + + $log('Declare processor queue: %s', $queue->getQueueName()); + $this->getContext()->declareQueue($queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + } + + protected function createTransportRouterTopicName(string $name, bool $prefix): string + { + $name = parent::createTransportRouterTopicName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } + + protected function createTransportQueueName(string $name, bool $prefix): string + { + $name = parent::createTransportQueueName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } +} diff --git a/pkg/enqueue/Client/Driver/StompDriver.php b/pkg/enqueue/Client/Driver/StompDriver.php new file mode 100644 index 000000000..811ad76e7 --- /dev/null +++ b/pkg/enqueue/Client/Driver/StompDriver.php @@ -0,0 +1,71 @@ +debug('[StompDriver] Stomp protocol does not support broker configuration'); + } + + /** + * @return StompMessage + */ + public function createTransportMessage(Message $message): InteropMessage + { + /** @var StompMessage $transportMessage */ + $transportMessage = parent::createTransportMessage($message); + $transportMessage->setPersistent(true); + + return $transportMessage; + } + + /** + * @return StompDestination + */ + protected function doCreateQueue(string $transportQueueName): InteropQueue + { + /** @var StompDestination $queue */ + $queue = parent::doCreateQueue($transportQueueName); + $queue->setDurable(true); + $queue->setAutoDelete(false); + $queue->setExclusive(false); + + return $queue; + } + + /** + * @return StompDestination + */ + protected function createRouterTopic(): Destination + { + /** @var StompDestination $topic */ + $topic = $this->doCreateTopic( + $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true) + ); + $topic->setDurable(true); + $topic->setAutoDelete(false); + + return $topic; + } +} diff --git a/pkg/enqueue/Client/Driver/StompManagementClient.php b/pkg/enqueue/Client/Driver/StompManagementClient.php new file mode 100644 index 000000000..0d64450dd --- /dev/null +++ b/pkg/enqueue/Client/Driver/StompManagementClient.php @@ -0,0 +1,44 @@ +client = $client; + $this->vhost = $vhost; + } + + public static function create(string $vhost = '/', string $host = 'localhost', int $port = 15672, string $login = 'guest', string $password = 'guest'): self + { + return new self(new Client(null, 'http://'.$host.':'.$port, $login, $password), $vhost); + } + + public function declareQueue(string $name, array $options) + { + return $this->client->queues()->create($this->vhost, $name, $options); + } + + public function declareExchange(string $name, array $options) + { + return $this->client->exchanges()->create($this->vhost, $name, $options); + } + + public function bind(string $exchange, string $queue, ?string $routingKey = null, $arguments = null) + { + return $this->client->bindings()->create($this->vhost, $exchange, $queue, $routingKey, $arguments); + } +} diff --git a/pkg/enqueue/Client/DriverFactory.php b/pkg/enqueue/Client/DriverFactory.php new file mode 100644 index 000000000..5c827e7e7 --- /dev/null +++ b/pkg/enqueue/Client/DriverFactory.php @@ -0,0 +1,91 @@ +getTransportOption('dsn'); + + if (empty($dsn)) { + throw new \LogicException('This driver factory relies on dsn option from transport config. The option is empty or not set.'); + } + + $dsn = Dsn::parseFirst($dsn); + + if ($driverInfo = $this->findDriverInfo($dsn, Resources::getAvailableDrivers())) { + $driverClass = $driverInfo['driverClass']; + + if (RabbitMqStompDriver::class === $driverClass) { + return $this->createRabbitMqStompDriver($factory, $dsn, $config, $collection); + } + + return new $driverClass($factory->createContext(), $config, $collection); + } + + $knownDrivers = Resources::getKnownDrivers(); + if ($driverInfo = $this->findDriverInfo($dsn, $knownDrivers)) { + throw new \LogicException(sprintf('To use given scheme "%s" a package has to be installed. Run "composer req %s" to add it.', $dsn->getScheme(), implode(' ', $driverInfo['packages']))); + } + + throw new \LogicException(sprintf('A given scheme "%s" is not supported. Maybe it is a custom driver, make sure you registered it with "%s::addDriver".', $dsn->getScheme(), Resources::class)); + } + + private function findDriverInfo(Dsn $dsn, array $factories): ?array + { + $protocol = $dsn->getSchemeProtocol(); + + if ($dsn->getSchemeExtensions()) { + foreach ($factories as $info) { + if (empty($info['requiredSchemeExtensions'])) { + continue; + } + + if (false == in_array($protocol, $info['schemes'], true)) { + continue; + } + + $diff = array_diff($dsn->getSchemeExtensions(), $info['requiredSchemeExtensions']); + if (empty($diff)) { + return $info; + } + } + } + + foreach ($factories as $driverClass => $info) { + if (false == empty($info['requiredSchemeExtensions'])) { + continue; + } + + if (false == in_array($protocol, $info['schemes'], true)) { + continue; + } + + return $info; + } + + return null; + } + + private function createRabbitMqStompDriver(ConnectionFactory $factory, Dsn $dsn, Config $config, RouteCollection $collection): RabbitMqStompDriver + { + $defaultManagementHost = $dsn->getHost() ?: $config->getTransportOption('host', 'localhost'); + $managementVast = ltrim($dsn->getPath() ?? '', '/') ?: $config->getTransportOption('vhost', '/'); + + $managementClient = StompManagementClient::create( + urldecode($managementVast), + $config->getDriverOption('rabbitmq_management_host', $defaultManagementHost), + $config->getDriverOption('rabbitmq_management_port', 15672), + (string) $dsn->getUser() ?: $config->getTransportOption('user', 'guest'), + (string) $dsn->getPassword() ?: $config->getTransportOption('pass', 'guest') + ); + + return new RabbitMqStompDriver($factory->createContext(), $config, $collection, $managementClient); + } +} diff --git a/pkg/enqueue/Client/DriverFactoryInterface.php b/pkg/enqueue/Client/DriverFactoryInterface.php new file mode 100644 index 000000000..698ad05a4 --- /dev/null +++ b/pkg/enqueue/Client/DriverFactoryInterface.php @@ -0,0 +1,10 @@ +message = $message; + $this->producer = $producer; + $this->driver = $driver; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getProducer(): ProducerInterface + { + return $this->producer; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function isEvent(): bool + { + return (bool) $this->message->getProperty(Config::TOPIC); + } + + public function isCommand(): bool + { + return (bool) $this->message->getProperty(Config::COMMAND); + } + + public function getCommand(): string + { + return $this->message->getProperty(Config::COMMAND); + } + + public function getTopic(): string + { + return $this->message->getProperty(Config::TOPIC); + } +} diff --git a/pkg/enqueue/Client/DriverPreSendExtensionInterface.php b/pkg/enqueue/Client/DriverPreSendExtensionInterface.php new file mode 100644 index 000000000..fd95c9328 --- /dev/null +++ b/pkg/enqueue/Client/DriverPreSendExtensionInterface.php @@ -0,0 +1,8 @@ +transportDestination = $transportDestination; + $this->transportMessage = $transportMessage; + } + + public function getTransportDestination(): Destination + { + return $this->transportDestination; + } + + public function getTransportMessage(): TransportMessage + { + return $this->transportMessage; + } +} diff --git a/pkg/enqueue/Client/Extension/PrepareBodyExtension.php b/pkg/enqueue/Client/Extension/PrepareBodyExtension.php new file mode 100644 index 000000000..e7924548c --- /dev/null +++ b/pkg/enqueue/Client/Extension/PrepareBodyExtension.php @@ -0,0 +1,51 @@ +prepareBody($context->getMessage()); + } + + public function onPreSendCommand(PreSend $context): void + { + $this->prepareBody($context->getMessage()); + } + + private function prepareBody(Message $message): void + { + $body = $message->getBody(); + $contentType = $message->getContentType(); + + if (is_scalar($body) || null === $body) { + $contentType = $contentType ?: 'text/plain'; + $body = (string) $body; + } elseif (is_array($body)) { + // only array of scalars is allowed. + array_walk_recursive($body, function ($value) { + if (!is_scalar($value) && null !== $value) { + throw new \LogicException(sprintf('The message\'s body must be an array of scalars. Found not scalar in the array: %s', is_object($value) ? $value::class : gettype($value))); + } + }); + + $contentType = $contentType ?: 'application/json'; + $body = JSON::encode($body); + } elseif ($body instanceof \JsonSerializable) { + $contentType = $contentType ?: 'application/json'; + $body = JSON::encode($body); + } else { + throw new \InvalidArgumentException(sprintf('The message\'s body must be either null, scalar, array or object (implements \JsonSerializable). Got: %s', is_object($body) ? $body::class : gettype($body))); + } + + $message->setContentType($contentType); + $message->setBody($body); + } +} diff --git a/pkg/enqueue/Client/ExtensionInterface.php b/pkg/enqueue/Client/ExtensionInterface.php new file mode 100644 index 000000000..596b1b9af --- /dev/null +++ b/pkg/enqueue/Client/ExtensionInterface.php @@ -0,0 +1,7 @@ +headers = []; - $this->properties = []; + $this->body = $body; + $this->headers = $headers; + $this->properties = $properties; + + $this->scope = static::SCOPE_MESSAGE_BUS; } /** - * @return null|string + * @return string|null */ public function getBody() { @@ -68,7 +96,7 @@ public function getBody() } /** - * @param null|string $body + * @param string|int|float|array|\JsonSerializable|null $body */ public function setBody($body) { @@ -177,6 +205,48 @@ public function setDelay($delay) $this->delay = $delay; } + public function setScope(string $scope): void + { + $this->scope = $scope; + } + + public function getScope(): string + { + return $this->scope; + } + + /** + * @return string + */ + public function getReplyTo() + { + return $this->replyTo; + } + + /** + * @param string $replyTo + */ + public function setReplyTo($replyTo) + { + $this->replyTo = $replyTo; + } + + /** + * @return string + */ + public function getCorrelationId() + { + return $this->correlationId; + } + + /** + * @param string $correlationId + */ + public function setCorrelationId($correlationId) + { + $this->correlationId = $correlationId; + } + /** * @return array */ @@ -186,10 +256,8 @@ public function getHeaders() } /** - * @param string $name - * @param mixed $default - * - * @return mixed + * @param string $name + * @param mixed|null $default */ public function getHeader($name, $default = null) { @@ -198,16 +266,12 @@ public function getHeader($name, $default = null) /** * @param string $name - * @param mixed $value */ public function setHeader($name, $value) { $this->headers[$name] = $value; } - /** - * @param array $headers - */ public function setHeaders(array $headers) { $this->headers = $headers; @@ -221,19 +285,14 @@ public function getProperties() return $this->properties; } - /** - * @param array $properties - */ public function setProperties(array $properties) { $this->properties = $properties; } /** - * @param string $name - * @param mixed $default - * - * @return mixed + * @param string $name + * @param mixed|null $default */ public function getProperty($name, $default = null) { @@ -242,7 +301,6 @@ public function getProperty($name, $default = null) /** * @param string $name - * @param mixed $value */ public function setProperty($name, $value) { diff --git a/pkg/enqueue/Client/MessagePriority.php b/pkg/enqueue/Client/MessagePriority.php index efa658c14..e14be9a7d 100644 --- a/pkg/enqueue/Client/MessagePriority.php +++ b/pkg/enqueue/Client/MessagePriority.php @@ -4,9 +4,9 @@ class MessagePriority { - const VERY_LOW = 'enqueue.message_queue.client.very_low_message_priority'; - const LOW = 'enqueue.message_queue.client.low_message_priority'; - const NORMAL = 'enqueue.message_queue.client.normal_message_priority'; - const HIGH = 'enqueue.message_queue.client.high_message_priority'; - const VERY_HIGH = 'enqueue.message_queue.client.very_high_message_priority'; + public const VERY_LOW = 'enqueue.message_queue.client.very_low_message_priority'; + public const LOW = 'enqueue.message_queue.client.low_message_priority'; + public const NORMAL = 'enqueue.message_queue.client.normal_message_priority'; + public const HIGH = 'enqueue.message_queue.client.high_message_priority'; + public const VERY_HIGH = 'enqueue.message_queue.client.very_high_message_priority'; } diff --git a/pkg/enqueue/Client/MessageProducer.php b/pkg/enqueue/Client/MessageProducer.php deleted file mode 100644 index 820ae0d2d..000000000 --- a/pkg/enqueue/Client/MessageProducer.php +++ /dev/null @@ -1,94 +0,0 @@ -driver = $driver; - } - - /** - * {@inheritdoc} - */ - public function send($topic, $message) - { - if (false == $message instanceof Message) { - $body = $message; - $message = new Message(); - $message->setBody($body); - } - - $this->prepareBody($message); - - $message->setProperty(Config::PARAMETER_TOPIC_NAME, $topic); - - if (!$message->getMessageId()) { - $message->setMessageId(UUID::generate()); - } - - if (!$message->getTimestamp()) { - $message->setTimestamp(time()); - } - - if (!$message->getPriority()) { - $message->setPriority(MessagePriority::NORMAL); - } - - $this->driver->sendToRouter($message); - } - - /** - * @param Message $message - */ - private function prepareBody(Message $message) - { - $body = $message->getBody(); - $contentType = $message->getContentType(); - - if (is_scalar($body) || is_null($body)) { - $contentType = $contentType ?: 'text/plain'; - $body = (string) $body; - } elseif (is_array($body)) { - $body = $message->getBody(); - $contentType = $message->getContentType(); - - if ($contentType && $contentType !== 'application/json') { - throw new \LogicException(sprintf('Content type "application/json" only allowed when body is array')); - } - - // only array of scalars is allowed. - array_walk_recursive($body, function ($value) { - if (!is_scalar($value) && !is_null($value)) { - throw new \LogicException(sprintf( - 'The message\'s body must be an array of scalars. Found not scalar in the array: %s', - is_object($value) ? get_class($value) : gettype($value) - )); - } - }); - - $contentType = 'application/json'; - $body = JSON::encode($body); - } else { - throw new \InvalidArgumentException(sprintf( - 'The message\'s body must be either null, scalar or array. Got: %s', - is_object($body) ? get_class($body) : gettype($body) - )); - } - - $message->setContentType($contentType); - $message->setBody($body); - } -} diff --git a/pkg/enqueue/Client/MessageProducerInterface.php b/pkg/enqueue/Client/MessageProducerInterface.php deleted file mode 100644 index c3d1bf8bd..000000000 --- a/pkg/enqueue/Client/MessageProducerInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -clientName = $clientName; - $this->transportName = $transportName; - $this->processors = $processors; - } - - /** - * @return string - */ - public function getClientName() - { - return $this->clientName; - } - - /** - * @return string - */ - public function getTransportName() - { - return $this->transportName; - } - - /** - * @return string[] - */ - public function getProcessors() - { - return $this->processors; - } -} diff --git a/pkg/enqueue/Client/Meta/QueueMetaRegistry.php b/pkg/enqueue/Client/Meta/QueueMetaRegistry.php deleted file mode 100644 index 71a2c0342..000000000 --- a/pkg/enqueue/Client/Meta/QueueMetaRegistry.php +++ /dev/null @@ -1,95 +0,0 @@ - [ - * 'transportName' => 'aTransportQueueName', - * 'processors' => ['aFooProcessorName', 'aBarProcessorName'], - * ] - * ]. - * - * - * @param Config $config - * @param array $meta - */ - public function __construct(Config $config, array $meta) - { - $this->config = $config; - $this->meta = $meta; - } - - /** - * @param string $queueName - * @param string|null $transportName - */ - public function add($queueName, $transportName = null) - { - $this->meta[$queueName] = [ - 'transportName' => $transportName, - 'processors' => [], - ]; - } - - /** - * @param string $queueName - * @param string $processorName - */ - public function addProcessor($queueName, $processorName) - { - if (false == array_key_exists($queueName, $this->meta)) { - $this->add($queueName); - } - - $this->meta[$queueName]['processors'][] = $processorName; - } - - /** - * @param string $queueName - * - * @return QueueMeta - */ - public function getQueueMeta($queueName) - { - if (false == array_key_exists($queueName, $this->meta)) { - throw new \InvalidArgumentException(sprintf( - 'The queue meta not found. Requested name `%s`', - $queueName - )); - } - - $transportName = $this->config->createTransportQueueName($queueName); - - $meta = array_replace([ - 'processors' => [], - 'transportName' => $transportName, - ], $this->meta[$queueName]); - - return new QueueMeta($queueName, $meta['transportName'], $meta['processors']); - } - - /** - * @return \Generator|QueueMeta[] - */ - public function getQueuesMeta() - { - foreach (array_keys($this->meta) as $queueName) { - yield $this->getQueueMeta($queueName); - } - } -} diff --git a/pkg/enqueue/Client/Meta/TopicMeta.php b/pkg/enqueue/Client/Meta/TopicMeta.php deleted file mode 100644 index abb0e33ed..000000000 --- a/pkg/enqueue/Client/Meta/TopicMeta.php +++ /dev/null @@ -1,57 +0,0 @@ -name = $name; - $this->description = $description; - $this->processors = $processors; - } - - /** - * @return string - */ - public function getName() - { - return $this->name; - } - - /** - * @return string - */ - public function getDescription() - { - return $this->description; - } - - /** - * @return string[] - */ - public function getProcessors() - { - return $this->processors; - } -} diff --git a/pkg/enqueue/Client/Meta/TopicMetaRegistry.php b/pkg/enqueue/Client/Meta/TopicMetaRegistry.php deleted file mode 100644 index efceb9a11..000000000 --- a/pkg/enqueue/Client/Meta/TopicMetaRegistry.php +++ /dev/null @@ -1,80 +0,0 @@ - [ - * 'description' => 'A desc', - * 'processors' => ['aProcessorNameFoo', 'aProcessorNameBar], - * ], - * ]. - * - * @param array $meta - */ - public function __construct(array $meta) - { - $this->meta = $meta; - } - - /** - * @param string $topicName - * @param string $description - */ - public function add($topicName, $description = null) - { - $this->meta[$topicName] = [ - 'description' => $description, - 'processors' => [], - ]; - } - - /** - * @param string $topicName - * @param string $processorName - */ - public function addProcessor($topicName, $processorName) - { - if (false == array_key_exists($topicName, $this->meta)) { - $this->add($topicName); - } - - $this->meta[$topicName]['processors'][] = $processorName; - } - - /** - * @param string $topicName - * - * @return TopicMeta - */ - public function getTopicMeta($topicName) - { - if (false == array_key_exists($topicName, $this->meta)) { - throw new \InvalidArgumentException(sprintf('The topic meta not found. Requested name `%s`', $topicName)); - } - - $topic = array_replace([ - 'description' => '', - 'processors' => [], - ], $this->meta[$topicName]); - - return new TopicMeta($topicName, $topic['description'], $topic['processors']); - } - - /** - * @return \Generator|TopicMeta[] - */ - public function getTopicsMeta() - { - foreach (array_keys($this->meta) as $topicName) { - yield $this->getTopicMeta($topicName); - } - } -} diff --git a/pkg/enqueue/Client/NullDriver.php b/pkg/enqueue/Client/NullDriver.php deleted file mode 100644 index dc2f74014..000000000 --- a/pkg/enqueue/Client/NullDriver.php +++ /dev/null @@ -1,143 +0,0 @@ -context = $session; - $this->config = $config; - } - - /** - * {@inheritdoc} - * - * @return NullMessage - */ - public function createTransportMessage(Message $message) - { - $headers = $message->getHeaders(); - $headers['content_type'] = $message->getContentType(); - $headers['expiration'] = $message->getExpire(); - $headers['delay'] = $message->getDelay(); - $headers['priority'] = $message->getPriority(); - - $transportMessage = $this->context->createMessage(); - $transportMessage->setBody($message->getBody()); - $transportMessage->setHeaders($headers); - $transportMessage->setProperties($message->getProperties()); - $transportMessage->setTimestamp($message->getTimestamp()); - $transportMessage->setMessageId($message->getMessageId()); - - return $transportMessage; - } - - /** - * {@inheritdoc} - * - * @param NullMessage $message - */ - public function createClientMessage(TransportMessage $message) - { - $clientMessage = new Message(); - $clientMessage->setBody($message->getBody()); - $clientMessage->setHeaders($message->getHeaders()); - $clientMessage->setProperties($message->getProperties()); - $clientMessage->setTimestamp($message->getTimestamp()); - $clientMessage->setMessageId($message->getMessageId()); - - if ($contentType = $message->getHeader('content_type')) { - $clientMessage->setContentType($contentType); - } - - if ($expiration = $message->getHeader('expiration')) { - $clientMessage->setExpire($expiration); - } - - if ($delay = $message->getHeader('delay')) { - $clientMessage->setDelay($delay); - } - - if ($priority = $message->getHeader('priority')) { - $clientMessage->setPriority($priority); - } - - return $clientMessage; - } - - /** - * {@inheritdoc} - */ - public function createQueue($queueName) - { - return $this->context->createQueue($queueName); - } - - /** - * {@inheritdoc} - */ - public function getConfig() - { - return $this->config; - } - - /** - * {@inheritdoc} - */ - public function sendToRouter(Message $message) - { - $transportMessage = $this->createTransportMessage($message); - $topic = $this->context->createTopic( - $this->config->createTransportRouterTopicName( - $this->config->getRouterTopicName() - ) - ); - - $this->context->createProducer()->send($topic, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function sendToProcessor(Message $message) - { - $transportMessage = $this->createTransportMessage($message); - $queue = $this->context->createQueue( - $this->config->createTransportQueueName( - $this->config->getRouterQueueName() - ) - ); - - $this->context->createProducer()->send($queue, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function setupBroker(LoggerInterface $logger = null) - { - $logger ?: new NullLogger(); - $logger->debug('[NullDriver] setup broker'); - } -} diff --git a/pkg/enqueue/Client/PostSend.php b/pkg/enqueue/Client/PostSend.php new file mode 100644 index 000000000..5d9526ea4 --- /dev/null +++ b/pkg/enqueue/Client/PostSend.php @@ -0,0 +1,78 @@ +message = $message; + $this->producer = $producer; + $this->driver = $driver; + $this->transportDestination = $transportDestination; + $this->transportMessage = $transportMessage; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getProducer(): ProducerInterface + { + return $this->producer; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function getTransportDestination(): Destination + { + return $this->transportDestination; + } + + public function getTransportMessage(): TransportMessage + { + return $this->transportMessage; + } + + public function isEvent(): bool + { + return (bool) $this->message->getProperty(Config::TOPIC); + } + + public function isCommand(): bool + { + return (bool) $this->message->getProperty(Config::COMMAND); + } + + public function getCommand(): string + { + return $this->message->getProperty(Config::COMMAND); + } + + public function getTopic(): string + { + return $this->message->getProperty(Config::TOPIC); + } +} diff --git a/pkg/enqueue/Client/PostSendExtensionInterface.php b/pkg/enqueue/Client/PostSendExtensionInterface.php new file mode 100644 index 000000000..dd3ca8b71 --- /dev/null +++ b/pkg/enqueue/Client/PostSendExtensionInterface.php @@ -0,0 +1,8 @@ +message = $message; + $this->commandOrTopic = $commandOrTopic; + $this->producer = $producer; + $this->driver = $driver; + + $this->originalMessage = clone $message; + } + + public function getCommand(): string + { + return $this->commandOrTopic; + } + + public function getTopic(): string + { + return $this->commandOrTopic; + } + + public function changeCommand(string $newCommand): void + { + $this->commandOrTopic = $newCommand; + } + + public function changeTopic(string $newTopic): void + { + $this->commandOrTopic = $newTopic; + } + + public function changeBody($body, ?string $contentType = null): void + { + $this->message->setBody($body); + + if (null !== $contentType) { + $this->message->setContentType($contentType); + } + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getOriginalMessage(): Message + { + return $this->originalMessage; + } + + public function getProducer(): ProducerInterface + { + return $this->producer; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } +} diff --git a/pkg/enqueue/Client/PreSendCommandExtensionInterface.php b/pkg/enqueue/Client/PreSendCommandExtensionInterface.php new file mode 100644 index 000000000..cefec097f --- /dev/null +++ b/pkg/enqueue/Client/PreSendCommandExtensionInterface.php @@ -0,0 +1,11 @@ +driver = $driver; + $this->rpcFactory = $rpcFactory; + + $this->extension = $extension ? + new ChainExtension([$extension, new PrepareBodyExtension()]) : + new ChainExtension([new PrepareBodyExtension()]) + ; + } + + public function sendEvent(string $topic, $message): void + { + if (false == $message instanceof Message) { + $message = new Message($message); + } + + $preSend = new PreSend($topic, $message, $this, $this->driver); + $this->extension->onPreSendEvent($preSend); + + $message = $preSend->getMessage(); + $message->setProperty(Config::TOPIC, $preSend->getTopic()); + + $this->doSend($message); + } + + public function sendCommand(string $command, $message, bool $needReply = false): ?Promise + { + if (false == $message instanceof Message) { + $message = new Message($message); + } + + $preSend = new PreSend($command, $message, $this, $this->driver); + $this->extension->onPreSendCommand($preSend); + + $command = $preSend->getCommand(); + $message = $preSend->getMessage(); + + $deleteReplyQueue = false; + $replyTo = $message->getReplyTo(); + + if ($needReply) { + if (false == $replyTo) { + $message->setReplyTo($replyTo = $this->rpcFactory->createReplyTo()); + $deleteReplyQueue = true; + } + + if (false == $message->getCorrelationId()) { + $message->setCorrelationId(UUID::generate()); + } + } + + $message->setProperty(Config::COMMAND, $command); + $message->setScope(Message::SCOPE_APP); + + $this->doSend($message); + + if ($needReply) { + $promise = $this->rpcFactory->createPromise($replyTo, $message->getCorrelationId(), 60000); + $promise->setDeleteReplyQueue($deleteReplyQueue); + + return $promise; + } + + return null; + } + + private function doSend(Message $message): void + { + if (false === is_string($message->getBody())) { + throw new \LogicException(sprintf('The message body must be string at this stage, got "%s". Make sure you passed string as message or there is an extension that converts custom input to string.', is_object($message->getBody()) ? get_class($message->getBody()) : gettype($message->getBody()))); + } + + if ($message->getProperty(Config::PROCESSOR)) { + throw new \LogicException(sprintf('The %s property must not be set.', Config::PROCESSOR)); + } + + if (!$message->getMessageId()) { + $message->setMessageId(UUID::generate()); + } + + if (!$message->getTimestamp()) { + $message->setTimestamp(time()); + } + + $this->extension->onDriverPreSend(new DriverPreSend($message, $this, $this->driver)); + + if (Message::SCOPE_MESSAGE_BUS == $message->getScope()) { + $result = $this->driver->sendToRouter($message); + } elseif (Message::SCOPE_APP == $message->getScope()) { + $result = $this->driver->sendToProcessor($message); + } else { + throw new \LogicException(sprintf('The message scope "%s" is not supported.', $message->getScope())); + } + + $this->extension->onPostSend(new PostSend($message, $this, $this->driver, $result->getTransportDestination(), $result->getTransportMessage())); + } +} diff --git a/pkg/enqueue/Client/ProducerInterface.php b/pkg/enqueue/Client/ProducerInterface.php new file mode 100644 index 000000000..3c884808a --- /dev/null +++ b/pkg/enqueue/Client/ProducerInterface.php @@ -0,0 +1,27 @@ + [ + * schemes => [schemes strings], + * package => package name, + * ]. + * + * @var array + */ + private static $knownDrivers; + + private function __construct() + { + } + + public static function getAvailableDrivers(): array + { + $map = self::getKnownDrivers(); + + $availableMap = []; + foreach ($map as $item) { + if (class_exists($item['driverClass'])) { + $availableMap[] = $item; + } + } + + return $availableMap; + } + + public static function getKnownDrivers(): array + { + if (null === self::$knownDrivers) { + $map = []; + + $map[] = [ + 'schemes' => ['amqp', 'amqps'], + 'driverClass' => AmqpDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/amqp-bunny'], + ]; + $map[] = [ + 'schemes' => ['amqp', 'amqps'], + 'driverClass' => RabbitMqDriver::class, + 'requiredSchemeExtensions' => ['rabbitmq'], + 'packages' => ['enqueue/enqueue', 'enqueue/amqp-bunny'], + ]; + $map[] = [ + 'schemes' => ['file'], + 'driverClass' => FsDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/fs'], + ]; + $map[] = [ + 'schemes' => ['null'], + 'driverClass' => GenericDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/null'], + ]; + $map[] = [ + 'schemes' => ['gps'], + 'driverClass' => GpsDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/gps'], + ]; + $map[] = [ + 'schemes' => ['redis', 'rediss'], + 'driverClass' => RedisDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/redis'], + ]; + $map[] = [ + 'schemes' => ['sqs'], + 'driverClass' => SqsDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/sqs'], + ]; + $map[] = [ + 'schemes' => ['sns'], + 'driverClass' => GenericDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/sns'], + ]; + $map[] = [ + 'schemes' => ['snsqs'], + 'driverClass' => SnsQsDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/sqs', 'enqueue/sns', 'enqueue/snsqs'], + ]; + $map[] = [ + 'schemes' => ['stomp'], + 'driverClass' => StompDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/stomp'], + ]; + $map[] = [ + 'schemes' => ['stomp'], + 'driverClass' => RabbitMqStompDriver::class, + 'requiredSchemeExtensions' => ['rabbitmq'], + 'packages' => ['enqueue/enqueue', 'enqueue/stomp'], + ]; + $map[] = [ + 'schemes' => ['kafka', 'rdkafka'], + 'driverClass' => RdKafkaDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/rdkafka'], + ]; + $map[] = [ + 'schemes' => ['mongodb'], + 'driverClass' => MongodbDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/mongodb'], + ]; + $map[] = [ + 'schemes' => [ + 'db2', + 'ibm-db2', + 'mssql', + 'sqlsrv', + 'mysql', + 'mysql2', + 'mysql', + 'pgsql', + 'postgres', + 'pgsql', + 'sqlite', + 'sqlite3', + 'sqlite', + ], + 'driverClass' => DbalDriver::class, + 'requiredSchemeExtensions' => [], + 'package' => ['enqueue/enqueue', 'enqueue/dbal'], + ]; + $map[] = [ + 'schemes' => ['gearman'], + 'driverClass' => GenericDriver::class, + 'requiredSchemeExtensions' => [], + 'package' => ['enqueue/enqueue', 'enqueue/gearman'], + ]; + $map[] = [ + 'schemes' => ['beanstalk'], + 'driverClass' => GenericDriver::class, + 'requiredSchemeExtensions' => [], + 'package' => ['enqueue/enqueue', 'enqueue/pheanstalk'], + ]; + + self::$knownDrivers = $map; + } + + return self::$knownDrivers; + } + + public static function addDriver(string $driverClass, array $schemes, array $requiredExtensions, array $packages): void + { + if (class_exists($driverClass)) { + if (false == (new \ReflectionClass($driverClass))->implementsInterface(DriverInterface::class)) { + throw new \InvalidArgumentException(sprintf('The driver class "%s" must implement "%s" interface.', $driverClass, DriverInterface::class)); + } + } + + if (empty($schemes)) { + throw new \InvalidArgumentException('Schemes could not be empty.'); + } + if (empty($packages)) { + throw new \InvalidArgumentException('Packages could not be empty.'); + } + + self::getKnownDrivers(); + self::$knownDrivers[] = [ + 'schemes' => $schemes, + 'driverClass' => $driverClass, + 'requiredSchemeExtensions' => $requiredExtensions, + 'packages' => $packages, + ]; + } +} diff --git a/pkg/enqueue/Client/Route.php b/pkg/enqueue/Client/Route.php new file mode 100644 index 000000000..8b9e31e36 --- /dev/null +++ b/pkg/enqueue/Client/Route.php @@ -0,0 +1,114 @@ +source = $source; + $this->sourceType = $sourceType; + $this->processor = $processor; + $this->options = $options; + } + + public function getSource(): string + { + return $this->source; + } + + public function isCommand(): bool + { + return self::COMMAND === $this->sourceType; + } + + public function isTopic(): bool + { + return self::TOPIC === $this->sourceType; + } + + public function getProcessor(): string + { + return $this->processor; + } + + public function isProcessorExclusive(): bool + { + return (bool) $this->getOption('exclusive', false); + } + + public function isProcessorExternal(): bool + { + return (bool) $this->getOption('external', false); + } + + public function getQueue(): ?string + { + return $this->getOption('queue'); + } + + public function isPrefixQueue(): bool + { + return (bool) $this->getOption('prefix_queue', true); + } + + public function getOptions(): array + { + return $this->options; + } + + public function getOption(string $name, $default = null) + { + return array_key_exists($name, $this->options) ? $this->options[$name] : $default; + } + + public function toArray(): array + { + return array_replace($this->options, [ + 'source' => $this->source, + 'source_type' => $this->sourceType, + 'processor' => $this->processor, + ]); + } + + public static function fromArray(array $route): self + { + list( + 'source' => $source, + 'source_type' => $sourceType, + 'processor' => $processor) = $route; + + unset($route['source'], $route['source_type'], $route['processor']); + $options = $route; + + return new self($source, $sourceType, $processor, $options); + } +} diff --git a/pkg/enqueue/Client/RouteCollection.php b/pkg/enqueue/Client/RouteCollection.php new file mode 100644 index 000000000..76bcbe451 --- /dev/null +++ b/pkg/enqueue/Client/RouteCollection.php @@ -0,0 +1,114 @@ +routes = $routes; + } + + public function add(Route $route): void + { + $this->routes[] = $route; + $this->topicRoutes = null; + $this->commandRoutes = null; + } + + /** + * @return Route[] + */ + public function all(): array + { + return $this->routes; + } + + /** + * @return Route[] + */ + public function command(string $command): ?Route + { + if (null === $this->commandRoutes) { + $commandRoutes = []; + foreach ($this->routes as $route) { + if ($route->isCommand()) { + $commandRoutes[$route->getSource()] = $route; + } + } + + $this->commandRoutes = $commandRoutes; + } + + return array_key_exists($command, $this->commandRoutes) ? $this->commandRoutes[$command] : null; + } + + /** + * @return Route[] + */ + public function topic(string $topic): array + { + if (null === $this->topicRoutes) { + $topicRoutes = []; + foreach ($this->routes as $route) { + if ($route->isTopic()) { + $topicRoutes[$route->getSource()][$route->getProcessor()] = $route; + } + } + + $this->topicRoutes = $topicRoutes; + } + + return array_key_exists($topic, $this->topicRoutes) ? $this->topicRoutes[$topic] : []; + } + + public function topicAndProcessor(string $topic, string $processor): ?Route + { + $routes = $this->topic($topic); + foreach ($routes as $route) { + if ($route->getProcessor() === $processor) { + return $route; + } + } + + return null; + } + + public function toArray(): array + { + $rawRoutes = []; + foreach ($this->routes as $route) { + $rawRoutes[] = $route->toArray(); + } + + return $rawRoutes; + } + + public static function fromArray(array $rawRoutes): self + { + $routes = []; + foreach ($rawRoutes as $rawRoute) { + $routes[] = Route::fromArray($rawRoute); + } + + return new self($routes); + } +} diff --git a/pkg/enqueue/Client/RouterProcessor.php b/pkg/enqueue/Client/RouterProcessor.php index 09f53d1b9..c441ceb8b 100644 --- a/pkg/enqueue/Client/RouterProcessor.php +++ b/pkg/enqueue/Client/RouterProcessor.php @@ -2,65 +2,63 @@ namespace Enqueue\Client; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Message as PsrMessage; -use Enqueue\Psr\Processor; +use Enqueue\Consumption\Result; +use Interop\Queue\Context; +use Interop\Queue\Message as InteropMessage; +use Interop\Queue\Processor; -class RouterProcessor implements Processor +final class RouterProcessor implements Processor { /** - * @var DriverInterface + * compatibility with 0.8x. */ - private $driver; + private const COMMAND_TOPIC_08X = '__command__'; /** - * @var array + * @var DriverInterface */ - private $routes; + private $driver; - /** - * @param DriverInterface $driver - * @param array $routes - */ - public function __construct(DriverInterface $driver, array $routes = []) + public function __construct(DriverInterface $driver) { $this->driver = $driver; - $this->routes = $routes; } - /** - * @param string $topicName - * @param string $queueName - * @param string $processorName - */ - public function add($topicName, $queueName, $processorName) + public function process(InteropMessage $message, Context $context): Result { - $this->routes[$topicName][] = [$processorName, $queueName]; - } + // compatibility with 0.8x + if (self::COMMAND_TOPIC_08X === $message->getProperty(Config::TOPIC)) { + $clientMessage = $this->driver->createClientMessage($message); + $clientMessage->setProperty(Config::TOPIC, null); - /** - * {@inheritdoc} - */ - public function process(PsrMessage $message, PsrContext $context) - { - $topicName = $message->getProperty(Config::PARAMETER_TOPIC_NAME); - if (false == $topicName) { - throw new \LogicException(sprintf( - 'Got message without required parameter: "%s"', - Config::PARAMETER_TOPIC_NAME + $this->driver->sendToProcessor($clientMessage); + + return Result::ack('Legacy 0.8x message routed to processor'); + } + // compatibility with 0.8x + + if ($message->getProperty(Config::COMMAND)) { + return Result::reject(sprintf( + 'Unexpected command "%s" got. Command must not go to the router.', + $message->getProperty(Config::COMMAND) )); } - if (array_key_exists($topicName, $this->routes)) { - foreach ($this->routes[$topicName] as $route) { - $processorMessage = clone $message; - $processorMessage->setProperty(Config::PARAMETER_PROCESSOR_NAME, $route[0]); - $processorMessage->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, $route[1]); + $topic = $message->getProperty(Config::TOPIC); + if (false == $topic) { + return Result::reject(sprintf('Topic property "%s" is required but not set or empty.', Config::TOPIC)); + } + + $count = 0; + foreach ($this->driver->getRouteCollection()->topic($topic) as $route) { + $clientMessage = $this->driver->createClientMessage($message); + $clientMessage->setProperty(Config::PROCESSOR, $route->getProcessor()); + + $this->driver->sendToProcessor($clientMessage); - $this->driver->sendToProcessor($this->driver->createClientMessage($processorMessage)); - } + ++$count; } - return self::ACK; + return Result::ack(sprintf('Routed to "%d" event subscribers', $count)); } } diff --git a/pkg/enqueue/Client/SimpleClient.php b/pkg/enqueue/Client/SimpleClient.php deleted file mode 100644 index 12ceac7f0..000000000 --- a/pkg/enqueue/Client/SimpleClient.php +++ /dev/null @@ -1,134 +0,0 @@ -context = $context; - $this->config = $config ?: Config::create(); - - $this->queueMetaRegistry = new QueueMetaRegistry($this->config, []); - $this->queueMetaRegistry->add($this->config->getDefaultProcessorQueueName()); - $this->queueMetaRegistry->add($this->config->getRouterQueueName()); - - $this->topicsMetaRegistry = new TopicMetaRegistry([]); - $this->processorsRegistry = new ArrayProcessorRegistry(); - - $this->driver = new AmqpDriver($context, $this->config, $this->queueMetaRegistry); - $this->routerProcessor = new RouterProcessor($this->driver, []); - - $this->processorsRegistry->add($this->config->getRouterProcessorName(), $this->routerProcessor); - $this->queueMetaRegistry->addProcessor($this->config->getRouterQueueName(), $this->routerProcessor); - } - - /** - * @param string $topic - * @param callback - */ - public function bind($topic, callable $processor) - { - $processorName = uniqid('', true); - $queueName = $this->config->getDefaultProcessorQueueName(); - - $this->topicsMetaRegistry->addProcessor($topic, $processorName); - $this->queueMetaRegistry->addProcessor($queueName, $processorName); - $this->processorsRegistry->add($processorName, new CallbackProcessor($processor)); - - $this->routerProcessor->add($topic, $queueName, $processorName); - } - - public function send($topic, $message) - { - $this->getProducer()->send($topic, $message); - } - - public function consume(ExtensionInterface $runtimeExtension = null) - { - $this->driver->setupBroker(); - - $processor = $this->getProcessor(); - - $queueConsumer = new QueueConsumer($this->context, new ChainExtension([ - new SetRouterPropertiesExtension($this->driver), - ])); - - $defaultQueueName = $this->config->getDefaultProcessorQueueName(); - $defaultTransportQueueName = $this->config->createTransportQueueName($defaultQueueName); - - $queueConsumer->bind($defaultTransportQueueName, $processor); - if ($this->config->getRouterQueueName() != $defaultQueueName) { - $routerTransportQueueName = $this->config->createTransportQueueName($this->config->getRouterQueueName()); - - $queueConsumer->bind($routerTransportQueueName, $processor); - } - - $queueConsumer->consume($runtimeExtension); - } - - /** - * @return MessageProducerInterface - */ - private function getProducer() - { - $this->driver->setupBroker(); - - return new MessageProducer($this->driver); - } - - /** - * @return DelegateProcessor - */ - private function getProcessor() - { - return new DelegateProcessor($this->processorsRegistry); - } -} diff --git a/pkg/enqueue/Client/SpoolProducer.php b/pkg/enqueue/Client/SpoolProducer.php new file mode 100644 index 000000000..8ad0940f5 --- /dev/null +++ b/pkg/enqueue/Client/SpoolProducer.php @@ -0,0 +1,65 @@ +realProducer = $realProducer; + + $this->events = new \SplQueue(); + $this->commands = new \SplQueue(); + } + + public function sendCommand(string $command, $message, bool $needReply = false): ?Promise + { + if ($needReply) { + return $this->realProducer->sendCommand($command, $message, $needReply); + } + + $this->commands->enqueue([$command, $message]); + + return null; + } + + public function sendEvent(string $topic, $message): void + { + $this->events->enqueue([$topic, $message]); + } + + /** + * When it is called it sends all previously queued messages. + */ + public function flush(): void + { + while (false == $this->events->isEmpty()) { + list($topic, $message) = $this->events->dequeue(); + + $this->realProducer->sendEvent($topic, $message); + } + + while (false == $this->commands->isEmpty()) { + list($command, $message) = $this->commands->dequeue(); + + $this->realProducer->sendCommand($command, $message); + } + } +} diff --git a/pkg/enqueue/Client/TopicSubscriberInterface.php b/pkg/enqueue/Client/TopicSubscriberInterface.php index 9d66d2cca..849a7827f 100644 --- a/pkg/enqueue/Client/TopicSubscriberInterface.php +++ b/pkg/enqueue/Client/TopicSubscriberInterface.php @@ -5,11 +5,37 @@ interface TopicSubscriberInterface { /** - * * ['topicName'] - * * ['topicName' => ['processorName' => 'processor', 'destinationName' => 'destination']] - * processorName, destinationName - optional. + * The result maybe either:. * - * @return array + * 'aTopicName' + * + * or + * + * ['aTopicName', 'anotherTopicName'] + * + * or + * + * [ + * [ + * 'topic' => 'aTopicName', + * 'processor' => 'fooProcessor', + * 'queue' => 'a_client_queue_name', + * + * 'aCustomOption' => 'aVal', + * ], + * [ + * 'topic' => 'anotherTopicName', + * 'processor' => 'barProcessor', + * 'queue' => 'a_client_queue_name', + * + * 'aCustomOption' => 'aVal', + * ], + * ] + * + * Note: If you set prefix_queue to true then the queue is used as is and therefor the driver is not used to prepare a transport queue name. + * It is possible to pass other options, they could be accessible on a route instance through options. + * + * @return string|array */ public static function getSubscribedTopics(); } diff --git a/pkg/enqueue/Client/TraceableMessageProducer.php b/pkg/enqueue/Client/TraceableMessageProducer.php deleted file mode 100644 index 7fa60b56a..000000000 --- a/pkg/enqueue/Client/TraceableMessageProducer.php +++ /dev/null @@ -1,87 +0,0 @@ -messageProducer = $messageProducer; - } - - /** - * {@inheritdoc} - */ - public function send($topic, $message) - { - $this->messageProducer->send($topic, $message); - - $trace = [ - 'topic' => $topic, - 'body' => $message, - 'headers' => [], - 'properties' => [], - 'priority' => null, - 'expire' => null, - 'delay' => null, - 'timestamp' => null, - 'contentType' => null, - 'messageId' => null, - ]; - if ($message instanceof Message) { - $trace['body'] = $message->getBody(); - $trace['headers'] = $message->getHeaders(); - $trace['properties'] = $message->getProperties(); - $trace['priority'] = $message->getPriority(); - $trace['expire'] = $message->getExpire(); - $trace['delay'] = $message->getDelay(); - $trace['timestamp'] = $message->getTimestamp(); - $trace['contentType'] = $message->getContentType(); - $trace['messageId'] = $message->getMessageId(); - } - - $this->traces[] = $trace; - } - - /** - * @param string $topic - * - * @return array - */ - public function getTopicTraces($topic) - { - $topicTraces = []; - foreach ($this->traces as $trace) { - if ($topic == $trace['topic']) { - $topicTraces[] = $trace; - } - } - - return $topicTraces; - } - - /** - * @return array - */ - public function getTraces() - { - return $this->traces; - } - - public function clearTraces() - { - $this->traces = []; - } -} diff --git a/pkg/enqueue/Client/TraceableProducer.php b/pkg/enqueue/Client/TraceableProducer.php new file mode 100644 index 000000000..b0bd613c3 --- /dev/null +++ b/pkg/enqueue/Client/TraceableProducer.php @@ -0,0 +1,105 @@ +producer = $producer; + } + + public function sendEvent(string $topic, $message): void + { + $this->producer->sendEvent($topic, $message); + + $this->collectTrace($topic, null, $message); + } + + public function sendCommand(string $command, $message, bool $needReply = false): ?Promise + { + $result = $this->producer->sendCommand($command, $message, $needReply); + + $this->collectTrace(null, $command, $message); + + return $result; + } + + public function getTopicTraces(string $topic): array + { + $topicTraces = []; + foreach ($this->traces as $trace) { + if ($topic == $trace['topic']) { + $topicTraces[] = $trace; + } + } + + return $topicTraces; + } + + public function getCommandTraces(string $command): array + { + $commandTraces = []; + foreach ($this->traces as $trace) { + if ($command == $trace['command']) { + $commandTraces[] = $trace; + } + } + + return $commandTraces; + } + + public function getTraces(): array + { + return $this->traces; + } + + public function clearTraces(): void + { + $this->traces = []; + } + + private function collectTrace(?string $topic, ?string $command, $message): void + { + $trace = [ + 'topic' => $topic, + 'command' => $command, + 'body' => $message, + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + 'sentAt' => (new \DateTime())->format('Y-m-d H:i:s.u'), + ]; + + if ($message instanceof Message) { + $trace['body'] = $message->getBody(); + $trace['headers'] = $message->getHeaders(); + $trace['properties'] = $message->getProperties(); + $trace['priority'] = $message->getPriority(); + $trace['expire'] = $message->getExpire(); + $trace['delay'] = $message->getDelay(); + $trace['timestamp'] = $message->getTimestamp(); + $trace['contentType'] = $message->getContentType(); + $trace['messageId'] = $message->getMessageId(); + } + + $this->traces[] = $trace; + } +} diff --git a/pkg/enqueue/ConnectionFactoryFactory.php b/pkg/enqueue/ConnectionFactoryFactory.php new file mode 100644 index 000000000..d23518c1b --- /dev/null +++ b/pkg/enqueue/ConnectionFactoryFactory.php @@ -0,0 +1,69 @@ + $config]; + } + + if (false == is_array($config)) { + throw new \InvalidArgumentException('The config must be either array or DSN string.'); + } + + if (false == array_key_exists('dsn', $config)) { + throw new \InvalidArgumentException('The config must have dsn key set.'); + } + + $dsn = Dsn::parseFirst($config['dsn']); + + if ($factoryClass = $this->findFactoryClass($dsn, Resources::getAvailableConnections())) { + return new $factoryClass(1 === count($config) ? $config['dsn'] : $config); + } + + $knownConnections = Resources::getKnownConnections(); + if ($factoryClass = $this->findFactoryClass($dsn, $knownConnections)) { + throw new \LogicException(sprintf('To use given scheme "%s" a package has to be installed. Run "composer req %s" to add it.', $dsn->getScheme(), $knownConnections[$factoryClass]['package'])); + } + + throw new \LogicException(sprintf('A given scheme "%s" is not supported. Maybe it is a custom connection, make sure you registered it with "%s::addConnection".', $dsn->getScheme(), Resources::class)); + } + + private function findFactoryClass(Dsn $dsn, array $factories): ?string + { + $protocol = $dsn->getSchemeProtocol(); + + if ($dsn->getSchemeExtensions()) { + foreach ($factories as $connectionClass => $info) { + if (empty($info['supportedSchemeExtensions'])) { + continue; + } + + if (false == in_array($protocol, $info['schemes'], true)) { + continue; + } + + $diff = array_diff($info['supportedSchemeExtensions'], $dsn->getSchemeExtensions()); + if (empty($diff)) { + return $connectionClass; + } + } + } + + foreach ($factories as $driverClass => $info) { + if (false == in_array($protocol, $info['schemes'], true)) { + continue; + } + + return $driverClass; + } + + return null; + } +} diff --git a/pkg/enqueue/ConnectionFactoryFactoryInterface.php b/pkg/enqueue/ConnectionFactoryFactoryInterface.php new file mode 100644 index 000000000..f4ca4a6d3 --- /dev/null +++ b/pkg/enqueue/ConnectionFactoryFactoryInterface.php @@ -0,0 +1,21 @@ +queue = $queue; + $this->processor = $processor; + } + + public function getQueue(): Queue + { + return $this->queue; + } + + public function getProcessor(): Processor + { + return $this->processor; + } +} diff --git a/pkg/enqueue/Consumption/CallbackProcessor.php b/pkg/enqueue/Consumption/CallbackProcessor.php index d9ef19d73..d15978fcb 100644 --- a/pkg/enqueue/Consumption/CallbackProcessor.php +++ b/pkg/enqueue/Consumption/CallbackProcessor.php @@ -2,9 +2,9 @@ namespace Enqueue\Consumption; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; +use Interop\Queue\Context; +use Interop\Queue\Message as InteropMessage; +use Interop\Queue\Processor; class CallbackProcessor implements Processor { @@ -13,18 +13,12 @@ class CallbackProcessor implements Processor */ private $callback; - /** - * @param callable $callback - */ public function __construct(callable $callback) { $this->callback = $callback; } - /** - * {@inheritdoc} - */ - public function process(Message $message, PsrContext $context) + public function process(InteropMessage $message, Context $context) { return call_user_func($this->callback, $message, $context); } diff --git a/pkg/enqueue/Consumption/ChainExtension.php b/pkg/enqueue/Consumption/ChainExtension.php index 17f5db659..83b4eba3a 100644 --- a/pkg/enqueue/Consumption/ChainExtension.php +++ b/pkg/enqueue/Consumption/ChainExtension.php @@ -2,80 +2,193 @@ namespace Enqueue\Consumption; +use Enqueue\Consumption\Context\End; +use Enqueue\Consumption\Context\InitLogger; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\Context\MessageResult; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\Context\PreSubscribe; +use Enqueue\Consumption\Context\ProcessorException; +use Enqueue\Consumption\Context\Start; + class ChainExtension implements ExtensionInterface { - use EmptyExtensionTrait; - - /** - * @var ExtensionInterface[] - */ - private $extensions; + private $startExtensions; + private $initLoggerExtensions; + private $preSubscribeExtensions; + private $preConsumeExtensions; + private $messageReceivedExtensions; + private $messageResultExtensions; + private $postMessageReceivedExtensions; + private $processorExceptionExtensions; + private $postConsumeExtensions; + private $endExtensions; - /** - * @param ExtensionInterface[] $extensions - */ public function __construct(array $extensions) { - $this->extensions = $extensions; + $this->startExtensions = []; + $this->initLoggerExtensions = []; + $this->preSubscribeExtensions = []; + $this->preConsumeExtensions = []; + $this->messageReceivedExtensions = []; + $this->messageResultExtensions = []; + $this->postMessageReceivedExtensions = []; + $this->processorExceptionExtensions = []; + $this->postConsumeExtensions = []; + $this->endExtensions = []; + + array_walk($extensions, function ($extension) { + if ($extension instanceof ExtensionInterface) { + $this->startExtensions[] = $extension; + $this->initLoggerExtensions[] = $extension; + $this->preSubscribeExtensions[] = $extension; + $this->preConsumeExtensions[] = $extension; + $this->messageReceivedExtensions[] = $extension; + $this->messageResultExtensions[] = $extension; + $this->postMessageReceivedExtensions[] = $extension; + $this->processorExceptionExtensions[] = $extension; + $this->postConsumeExtensions[] = $extension; + $this->endExtensions[] = $extension; + + return; + } + + $extensionValid = false; + if ($extension instanceof StartExtensionInterface) { + $this->startExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof InitLoggerExtensionInterface) { + $this->initLoggerExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PreSubscribeExtensionInterface) { + $this->preSubscribeExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PreConsumeExtensionInterface) { + $this->preConsumeExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof MessageReceivedExtensionInterface) { + $this->messageReceivedExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof MessageResultExtensionInterface) { + $this->messageResultExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof ProcessorExceptionExtensionInterface) { + $this->processorExceptionExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PostMessageReceivedExtensionInterface) { + $this->postMessageReceivedExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof PostConsumeExtensionInterface) { + $this->postConsumeExtensions[] = $extension; + + $extensionValid = true; + } + + if ($extension instanceof EndExtensionInterface) { + $this->endExtensions[] = $extension; + + $extensionValid = true; + } + + if (false == $extensionValid) { + throw new \LogicException(sprintf('Invalid extension given %s', $extension::class)); + } + }); + } + + public function onInitLogger(InitLogger $context): void + { + foreach ($this->initLoggerExtensions as $extension) { + $extension->onInitLogger($context); + } } - /** - * @param Context $context - */ - public function onStart(Context $context) + public function onStart(Start $context): void { - foreach ($this->extensions as $extension) { + foreach ($this->startExtensions as $extension) { $extension->onStart($context); } } - /** - * @param Context $context - */ - public function onBeforeReceive(Context $context) + public function onPreSubscribe(PreSubscribe $context): void + { + foreach ($this->preSubscribeExtensions as $extension) { + $extension->onPreSubscribe($context); + } + } + + public function onPreConsume(PreConsume $context): void + { + foreach ($this->preConsumeExtensions as $extension) { + $extension->onPreConsume($context); + } + } + + public function onMessageReceived(MessageReceived $context): void + { + foreach ($this->messageReceivedExtensions as $extension) { + $extension->onMessageReceived($context); + } + } + + public function onResult(MessageResult $context): void { - foreach ($this->extensions as $extension) { - $extension->onBeforeReceive($context); + foreach ($this->messageResultExtensions as $extension) { + $extension->onResult($context); } } - /** - * @param Context $context - */ - public function onPreReceived(Context $context) + public function onProcessorException(ProcessorException $context): void { - foreach ($this->extensions as $extension) { - $extension->onPreReceived($context); + foreach ($this->processorExceptionExtensions as $extension) { + $extension->onProcessorException($context); } } - /** - * @param Context $context - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { - foreach ($this->extensions as $extension) { - $extension->onPostReceived($context); + foreach ($this->postMessageReceivedExtensions as $extension) { + $extension->onPostMessageReceived($context); } } - /** - * @param Context $context - */ - public function onIdle(Context $context) + public function onPostConsume(PostConsume $context): void { - foreach ($this->extensions as $extension) { - $extension->onIdle($context); + foreach ($this->postConsumeExtensions as $extension) { + $extension->onPostConsume($context); } } - /** - * @param Context $context - */ - public function onInterrupted(Context $context) + public function onEnd(End $context): void { - foreach ($this->extensions as $extension) { - $extension->onInterrupted($context); + foreach ($this->endExtensions as $extension) { + $extension->onEnd($context); } } } diff --git a/pkg/enqueue/Consumption/Context.php b/pkg/enqueue/Consumption/Context.php deleted file mode 100644 index d753ff806..000000000 --- a/pkg/enqueue/Consumption/Context.php +++ /dev/null @@ -1,233 +0,0 @@ -psrContext = $psrContext; - - $this->executionInterrupted = false; - } - - /** - * @return \Enqueue\Psr\Message - */ - public function getPsrMessage() - { - return $this->psrMessage; - } - - /** - * @param Message $psrMessage - */ - public function setPsrMessage(Message $psrMessage) - { - if ($this->psrMessage) { - throw new IllegalContextModificationException('The message could be set once'); - } - - $this->psrMessage = $psrMessage; - } - - /** - * @return PsrContext - */ - public function getPsrContext() - { - return $this->psrContext; - } - - /** - * @return Consumer - */ - public function getPsrConsumer() - { - return $this->psrConsumer; - } - - /** - * @param Consumer $psrConsumer - */ - public function setPsrConsumer(Consumer $psrConsumer) - { - if ($this->psrConsumer) { - throw new IllegalContextModificationException('The message consumer could be set once'); - } - - $this->psrConsumer = $psrConsumer; - } - - /** - * @return Processor - */ - public function getPsrProcessor() - { - return $this->psrProcessor; - } - - /** - * @param Processor $psrProcessor - */ - public function setPsrProcessor(Processor $psrProcessor) - { - if ($this->psrProcessor) { - throw new IllegalContextModificationException('The message processor could be set once'); - } - - $this->psrProcessor = $psrProcessor; - } - - /** - * @return \Exception - */ - public function getException() - { - return $this->exception; - } - - /** - * @param \Exception $exception - */ - public function setException(\Exception $exception) - { - $this->exception = $exception; - } - - /** - * @return Result|string - */ - public function getResult() - { - return $this->result; - } - - /** - * @param Result|string $result - */ - public function setResult($result) - { - if ($this->result) { - throw new IllegalContextModificationException('The result modification is not allowed'); - } - - $this->result = $result; - } - - /** - * @return bool - */ - public function isExecutionInterrupted() - { - return $this->executionInterrupted; - } - - /** - * @param bool $executionInterrupted - */ - public function setExecutionInterrupted($executionInterrupted) - { - if (false == $executionInterrupted && $this->executionInterrupted) { - throw new IllegalContextModificationException('The execution once interrupted could not be roll backed'); - } - - $this->executionInterrupted = $executionInterrupted; - } - - /** - * @return LoggerInterface - */ - public function getLogger() - { - return $this->logger; - } - - /** - * @param LoggerInterface $logger - */ - public function setLogger(LoggerInterface $logger) - { - if ($this->logger) { - throw new IllegalContextModificationException('The logger modification is not allowed'); - } - - $this->logger = $logger; - } - - /** - * @return \Enqueue\Psr\Queue - */ - public function getPsrQueue() - { - return $this->psrQueue; - } - - /** - * @param \Enqueue\Psr\Queue $psrQueue - */ - public function setPsrQueue(Queue $psrQueue) - { - if ($this->psrQueue) { - throw new IllegalContextModificationException('The queue modification is not allowed'); - } - - $this->psrQueue = $psrQueue; - } -} diff --git a/pkg/enqueue/Consumption/Context/End.php b/pkg/enqueue/Consumption/Context/End.php new file mode 100644 index 000000000..07853b3d3 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/End.php @@ -0,0 +1,79 @@ +context = $context; + $this->logger = $logger; + $this->startTime = $startTime; + $this->endTime = $endTime; + $this->exitStatus = $exitStatus; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + /** + * In milliseconds. + */ + public function getStartTime(): int + { + return $this->startTime; + } + + /** + * In milliseconds. + */ + public function getEndTime(): int + { + return $this->startTime; + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } +} diff --git a/pkg/enqueue/Consumption/Context/InitLogger.php b/pkg/enqueue/Consumption/Context/InitLogger.php new file mode 100644 index 000000000..c48057268 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/InitLogger.php @@ -0,0 +1,28 @@ +logger = $logger; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function changeLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } +} diff --git a/pkg/enqueue/Consumption/Context/MessageReceived.php b/pkg/enqueue/Consumption/Context/MessageReceived.php new file mode 100644 index 000000000..35abf1ca8 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/MessageReceived.php @@ -0,0 +1,109 @@ +context = $context; + $this->consumer = $consumer; + $this->message = $message; + $this->processor = $processor; + $this->receivedAt = $receivedAt; + $this->logger = $logger; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getConsumer(): Consumer + { + return $this->consumer; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getProcessor(): Processor + { + return $this->processor; + } + + public function changeProcessor(Processor $processor): void + { + $this->processor = $processor; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getReceivedAt(): int + { + return $this->receivedAt; + } + + public function getResult(): ?Result + { + return $this->result; + } + + public function setResult(Result $result): void + { + $this->result = $result; + } +} diff --git a/pkg/enqueue/Consumption/Context/MessageResult.php b/pkg/enqueue/Consumption/Context/MessageResult.php new file mode 100644 index 000000000..4fa8f7de0 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/MessageResult.php @@ -0,0 +1,93 @@ +context = $context; + $this->consumer = $consumer; + $this->message = $message; + $this->logger = $logger; + $this->result = $result; + $this->receivedAt = $receivedAt; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getConsumer(): Consumer + { + return $this->consumer; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getReceivedAt(): int + { + return $this->receivedAt; + } + + /** + * @return Result|object|string|null + */ + public function getResult() + { + return $this->result; + } + + /** + * @param Result|string|object|null $result + */ + public function changeResult($result): void + { + $this->result = $result; + } +} diff --git a/pkg/enqueue/Consumption/Context/PostConsume.php b/pkg/enqueue/Consumption/Context/PostConsume.php new file mode 100644 index 000000000..a6f1d8375 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/PostConsume.php @@ -0,0 +1,108 @@ +context = $context; + $this->subscriptionConsumer = $subscriptionConsumer; + $this->receivedMessagesCount = $receivedMessagesCount; + $this->cycle = $cycle; + $this->startTime = $startTime; + $this->logger = $logger; + + $this->executionInterrupted = false; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getSubscriptionConsumer(): SubscriptionConsumer + { + return $this->subscriptionConsumer; + } + + public function getReceivedMessagesCount(): int + { + return $this->receivedMessagesCount; + } + + public function getCycle(): int + { + return $this->cycle; + } + + public function getStartTime(): int + { + return $this->startTime; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } + + public function isExecutionInterrupted(): bool + { + return $this->executionInterrupted; + } + + public function interruptExecution(?int $exitStatus = null): void + { + $this->exitStatus = $exitStatus; + $this->executionInterrupted = true; + } +} diff --git a/pkg/enqueue/Consumption/Context/PostMessageReceived.php b/pkg/enqueue/Consumption/Context/PostMessageReceived.php new file mode 100644 index 000000000..23df2c849 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/PostMessageReceived.php @@ -0,0 +1,119 @@ +context = $context; + $this->consumer = $consumer; + $this->message = $message; + $this->result = $result; + $this->receivedAt = $receivedAt; + $this->logger = $logger; + + $this->executionInterrupted = false; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getConsumer(): Consumer + { + return $this->consumer; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getReceivedAt(): int + { + return $this->receivedAt; + } + + /** + * @return Result|object|string|null + */ + public function getResult() + { + return $this->result; + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } + + public function isExecutionInterrupted(): bool + { + return $this->executionInterrupted; + } + + public function interruptExecution(?int $exitStatus = null): void + { + $this->exitStatus = $exitStatus; + $this->executionInterrupted = true; + } +} diff --git a/pkg/enqueue/Consumption/Context/PreConsume.php b/pkg/enqueue/Consumption/Context/PreConsume.php new file mode 100644 index 000000000..77cc7d030 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/PreConsume.php @@ -0,0 +1,108 @@ +context = $context; + $this->subscriptionConsumer = $subscriptionConsumer; + $this->logger = $logger; + $this->cycle = $cycle; + $this->receiveTimeout = $receiveTimeout; + $this->startTime = $startTime; + + $this->executionInterrupted = false; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getSubscriptionConsumer(): SubscriptionConsumer + { + return $this->subscriptionConsumer; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getCycle(): int + { + return $this->cycle; + } + + public function getReceiveTimeout(): int + { + return $this->receiveTimeout; + } + + public function getStartTime(): int + { + return $this->startTime; + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } + + public function isExecutionInterrupted(): bool + { + return $this->executionInterrupted; + } + + public function interruptExecution(?int $exitStatus = null): void + { + $this->exitStatus = $exitStatus; + $this->executionInterrupted = true; + } +} diff --git a/pkg/enqueue/Consumption/Context/PreSubscribe.php b/pkg/enqueue/Consumption/Context/PreSubscribe.php new file mode 100644 index 000000000..dbc74bb69 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/PreSubscribe.php @@ -0,0 +1,59 @@ +context = $context; + $this->processor = $processor; + $this->consumer = $consumer; + $this->logger = $logger; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getProcessor(): Processor + { + return $this->processor; + } + + public function getConsumer(): Consumer + { + return $this->consumer; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } +} diff --git a/pkg/enqueue/Consumption/Context/ProcessorException.php b/pkg/enqueue/Consumption/Context/ProcessorException.php new file mode 100644 index 000000000..329b13d93 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/ProcessorException.php @@ -0,0 +1,96 @@ +context = $context; + $this->consumer = $consumer; + $this->message = $message; + $this->exception = $exception; + $this->logger = $logger; + $this->receivedAt = $receivedAt; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getConsumer(): Consumer + { + return $this->consumer; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getException(): \Throwable + { + return $this->exception; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getReceivedAt(): int + { + return $this->receivedAt; + } + + public function getResult(): ?Result + { + return $this->result; + } + + public function setResult(Result $result): void + { + $this->result = $result; + } +} diff --git a/pkg/enqueue/Consumption/Context/Start.php b/pkg/enqueue/Consumption/Context/Start.php new file mode 100644 index 000000000..84db29c44 --- /dev/null +++ b/pkg/enqueue/Consumption/Context/Start.php @@ -0,0 +1,128 @@ +context = $context; + $this->logger = $logger; + $this->processors = $processors; + $this->receiveTimeout = $receiveTimeout; + $this->startTime = $startTime; + + $this->executionInterrupted = false; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + /** + * In milliseconds. + */ + public function getReceiveTimeout(): int + { + return $this->receiveTimeout; + } + + /** + * In milliseconds. + */ + public function changeReceiveTimeout(int $timeout): void + { + $this->receiveTimeout = $timeout; + } + + /** + * In milliseconds. + */ + public function getStartTime(): int + { + return $this->startTime; + } + + /** + * @return BoundProcessor[] + */ + public function getBoundProcessors(): array + { + return $this->processors; + } + + /** + * @param BoundProcessor[] $processors + */ + public function changeBoundProcessors(array $processors): void + { + $this->processors = []; + array_walk($processors, function (BoundProcessor $processor) { + $this->processors[] = $processor; + }); + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } + + public function isExecutionInterrupted(): bool + { + return $this->executionInterrupted; + } + + public function interruptExecution(?int $exitStatus = null): void + { + $this->exitStatus = $exitStatus; + $this->executionInterrupted = true; + } +} diff --git a/pkg/enqueue/Consumption/EmptyExtensionTrait.php b/pkg/enqueue/Consumption/EmptyExtensionTrait.php deleted file mode 100644 index c98ed8da3..000000000 --- a/pkg/enqueue/Consumption/EmptyExtensionTrait.php +++ /dev/null @@ -1,48 +0,0 @@ -exitStatus = $context->getExitStatus(); + } + + public function getExitStatus(): ?int + { + return $this->exitStatus; + } +} diff --git a/pkg/enqueue/Consumption/Extension/LimitConsumedMessagesExtension.php b/pkg/enqueue/Consumption/Extension/LimitConsumedMessagesExtension.php index ef6ec527e..0dc6feceb 100644 --- a/pkg/enqueue/Consumption/Extension/LimitConsumedMessagesExtension.php +++ b/pkg/enqueue/Consumption/Extension/LimitConsumedMessagesExtension.php @@ -2,14 +2,14 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; +use Enqueue\Consumption\PreConsumeExtensionInterface; +use Psr\Log\LoggerInterface; -class LimitConsumedMessagesExtension implements ExtensionInterface +class LimitConsumedMessagesExtension implements PreConsumeExtensionInterface, PostMessageReceivedExtensionInterface { - use EmptyExtensionTrait; - /** * @var int */ @@ -20,54 +20,41 @@ class LimitConsumedMessagesExtension implements ExtensionInterface */ protected $messageConsumed; - /** - * @param int $messageLimit - */ - public function __construct($messageLimit) + public function __construct(int $messageLimit) { - if (false == is_int($messageLimit)) { - throw new \InvalidArgumentException(sprintf( - 'Expected message limit is int but got: "%s"', - is_object($messageLimit) ? get_class($messageLimit) : gettype($messageLimit) - )); - } - $this->messageLimit = $messageLimit; $this->messageConsumed = 0; } - /** - * {@inheritdoc} - */ - public function onBeforeReceive(Context $context) + public function onPreConsume(PreConsume $context): void { // this is added here to handle an edge case. when a user sets zero as limit. - $this->checkMessageLimit($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { ++$this->messageConsumed; - $this->checkMessageLimit($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * @param Context $context - */ - protected function checkMessageLimit(Context $context) + protected function shouldBeStopped(LoggerInterface $logger): bool { if ($this->messageConsumed >= $this->messageLimit) { - $context->getLogger()->debug(sprintf( + $logger->debug(sprintf( '[LimitConsumedMessagesExtension] Message consumption is interrupted since the message limit reached.'. ' limit: "%s"', $this->messageLimit )); - $context->setExecutionInterrupted(true); + return true; } + + return false; } } diff --git a/pkg/enqueue/Consumption/Extension/LimitConsumerMemoryExtension.php b/pkg/enqueue/Consumption/Extension/LimitConsumerMemoryExtension.php index c03686f33..7edbf232c 100644 --- a/pkg/enqueue/Consumption/Extension/LimitConsumerMemoryExtension.php +++ b/pkg/enqueue/Consumption/Extension/LimitConsumerMemoryExtension.php @@ -2,14 +2,16 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\PostConsumeExtensionInterface; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; +use Enqueue\Consumption\PreConsumeExtensionInterface; +use Psr\Log\LoggerInterface; -class LimitConsumerMemoryExtension implements ExtensionInterface +class LimitConsumerMemoryExtension implements PreConsumeExtensionInterface, PostMessageReceivedExtensionInterface, PostConsumeExtensionInterface { - use EmptyExtensionTrait; - /** * @var int */ @@ -21,53 +23,46 @@ class LimitConsumerMemoryExtension implements ExtensionInterface public function __construct($memoryLimit) { if (false == is_int($memoryLimit)) { - throw new \InvalidArgumentException(sprintf( - 'Expected memory limit is int but got: "%s"', - is_object($memoryLimit) ? get_class($memoryLimit) : gettype($memoryLimit) - )); + throw new \InvalidArgumentException(sprintf('Expected memory limit is int but got: "%s"', is_object($memoryLimit) ? $memoryLimit::class : gettype($memoryLimit))); } $this->memoryLimit = $memoryLimit * 1024 * 1024; } - /** - * {@inheritdoc} - */ - public function onBeforeReceive(Context $context) + public function onPreConsume(PreConsume $context): void { - $this->checkMemory($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { - $this->checkMemory($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onIdle(Context $context) + public function onPostConsume(PostConsume $context): void { - $this->checkMemory($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * @param Context $context - */ - protected function checkMemory(Context $context) + protected function shouldBeStopped(LoggerInterface $logger): bool { $memoryUsage = memory_get_usage(true); if ($memoryUsage >= $this->memoryLimit) { - $context->getLogger()->debug(sprintf( + $logger->debug(sprintf( '[LimitConsumerMemoryExtension] Interrupt execution as memory limit reached. limit: "%s", used: "%s"', $this->memoryLimit, $memoryUsage )); - $context->setExecutionInterrupted(true); + return true; } + + return false; } } diff --git a/pkg/enqueue/Consumption/Extension/LimitConsumptionTimeExtension.php b/pkg/enqueue/Consumption/Extension/LimitConsumptionTimeExtension.php index 65221f5c0..1953aa2e6 100644 --- a/pkg/enqueue/Consumption/Extension/LimitConsumptionTimeExtension.php +++ b/pkg/enqueue/Consumption/Extension/LimitConsumptionTimeExtension.php @@ -2,66 +2,61 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; - -class LimitConsumptionTimeExtension implements ExtensionInterface +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\PostConsumeExtensionInterface; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; +use Enqueue\Consumption\PreConsumeExtensionInterface; +use Psr\Log\LoggerInterface; + +class LimitConsumptionTimeExtension implements PreConsumeExtensionInterface, PostConsumeExtensionInterface, PostMessageReceivedExtensionInterface { - use EmptyExtensionTrait; - /** * @var \DateTime */ protected $timeLimit; - /** - * @param \DateTime $timeLimit - */ public function __construct(\DateTime $timeLimit) { $this->timeLimit = $timeLimit; } - /** - * {@inheritdoc} - */ - public function onBeforeReceive(Context $context) + public function onPreConsume(PreConsume $context): void { - $this->checkTime($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onIdle(Context $context) + public function onPostConsume(PostConsume $context): void { - $this->checkTime($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { - $this->checkTime($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * @param Context $context - */ - protected function checkTime(Context $context) + protected function shouldBeStopped(LoggerInterface $logger): bool { $now = new \DateTime(); if ($now >= $this->timeLimit) { - $context->getLogger()->debug(sprintf( + $logger->debug(sprintf( '[LimitConsumptionTimeExtension] Execution interrupted as limit time has passed.'. ' now: "%s", time-limit: "%s"', - $now->format(DATE_ISO8601), - $this->timeLimit->format(DATE_ISO8601) + $now->format(\DATE_ISO8601), + $this->timeLimit->format(\DATE_ISO8601) )); - $context->setExecutionInterrupted(true); + return true; } + + return false; } } diff --git a/pkg/enqueue/Consumption/Extension/LogExtension.php b/pkg/enqueue/Consumption/Extension/LogExtension.php new file mode 100644 index 000000000..14383c4d1 --- /dev/null +++ b/pkg/enqueue/Consumption/Extension/LogExtension.php @@ -0,0 +1,67 @@ +getLogger()->debug('Consumption has started'); + } + + public function onEnd(End $context): void + { + $context->getLogger()->debug('Consumption has ended'); + } + + public function onMessageReceived(MessageReceived $context): void + { + $message = $context->getMessage(); + + $context->getLogger()->debug("Received from {queueName}\t{body}", [ + 'queueName' => $context->getConsumer()->getQueue()->getQueueName(), + 'redelivered' => $message->isRedelivered(), + 'body' => Stringify::that($message->getBody()), + 'properties' => Stringify::that($message->getProperties()), + 'headers' => Stringify::that($message->getHeaders()), + ]); + } + + public function onPostMessageReceived(PostMessageReceived $context): void + { + $message = $context->getMessage(); + $queue = $context->getConsumer()->getQueue(); + $result = $context->getResult(); + + $reason = ''; + $logMessage = "Processed from {queueName}\t{body}\t{result}"; + if ($result instanceof Result && $result->getReason()) { + $reason = $result->getReason(); + $logMessage .= ' {reason}'; + } + $logContext = [ + 'result' => str_replace('enqueue.', '', $result), + 'reason' => $reason, + 'queueName' => $queue->getQueueName(), + 'body' => Stringify::that($message->getBody()), + 'properties' => Stringify::that($message->getProperties()), + 'headers' => Stringify::that($message->getHeaders()), + ]; + + $logLevel = Result::REJECT == ((string) $result) ? LogLevel::ERROR : LogLevel::INFO; + + $context->getLogger()->log($logLevel, $logMessage, $logContext); + } +} diff --git a/pkg/enqueue/Consumption/Extension/LoggerExtension.php b/pkg/enqueue/Consumption/Extension/LoggerExtension.php index 57823bca0..90e92be8a 100644 --- a/pkg/enqueue/Consumption/Extension/LoggerExtension.php +++ b/pkg/enqueue/Consumption/Extension/LoggerExtension.php @@ -2,81 +2,30 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; -use Enqueue\Consumption\Result; -use Enqueue\Psr\Message; +use Enqueue\Consumption\Context\InitLogger; +use Enqueue\Consumption\InitLoggerExtensionInterface; use Psr\Log\LoggerInterface; -class LoggerExtension implements ExtensionInterface +class LoggerExtension implements InitLoggerExtensionInterface { - use EmptyExtensionTrait; - /** * @var LoggerInterface */ private $logger; - /** - * @param LoggerInterface $logger - */ public function __construct(LoggerInterface $logger) { $this->logger = $logger; } - /** - * {@inheritdoc} - */ - public function onStart(Context $context) - { - $context->setLogger($this->logger); - $this->logger->debug(sprintf('Set context\'s logger %s', get_class($this->logger))); - } - - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onInitLogger(InitLogger $context): void { - if (false == $context->getResult() instanceof Result) { - return; - } - - /** @var $result Result */ - $result = $context->getResult(); + $previousLogger = $context->getLogger(); - switch ($result->getStatus()) { - case Result::REJECT: - case Result::REQUEUE: - if ($result->getReason()) { - $this->logger->error($result->getReason(), $this->messageToLogContext($context->getPsrMessage())); - } + if ($previousLogger !== $this->logger) { + $context->changeLogger($this->logger); - break; - case Result::ACK: - if ($result->getReason()) { - $this->logger->info($result->getReason(), $this->messageToLogContext($context->getPsrMessage())); - } - - break; - default: - throw new \LogicException(sprintf('Got unexpected message result. "%s"', $result->getStatus())); + $this->logger->debug(sprintf('Change logger from "%s" to "%s"', $previousLogger::class, get_class($this->logger))); } } - - /** - * @param Message $message - * - * @return array - */ - private function messageToLogContext(Message $message) - { - return [ - 'body' => $message->getBody(), - 'headers' => $message->getHeaders(), - 'properties' => $message->getProperties(), - ]; - } } diff --git a/pkg/enqueue/Consumption/Extension/NicenessExtension.php b/pkg/enqueue/Consumption/Extension/NicenessExtension.php new file mode 100644 index 000000000..436a8ec0f --- /dev/null +++ b/pkg/enqueue/Consumption/Extension/NicenessExtension.php @@ -0,0 +1,38 @@ +niceness = $niceness; + } + + public function onStart(Start $context): void + { + if (0 !== $this->niceness) { + $changed = @proc_nice($this->niceness); + if (!$changed) { + throw new \InvalidArgumentException(sprintf('Cannot change process niceness, got warning: "%s"', error_get_last()['message'])); + } + } + } +} diff --git a/pkg/enqueue/Consumption/Extension/ReplyExtension.php b/pkg/enqueue/Consumption/Extension/ReplyExtension.php index d1f00443c..c1ac19bd7 100644 --- a/pkg/enqueue/Consumption/Extension/ReplyExtension.php +++ b/pkg/enqueue/Consumption/Extension/ReplyExtension.php @@ -2,22 +2,15 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; use Enqueue\Consumption\Result; -class ReplyExtension implements ExtensionInterface +class ReplyExtension implements PostMessageReceivedExtensionInterface { - use EmptyExtensionTrait; - - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { - $replyTo = $context->getPsrMessage()->getReplyTo(); - $correlationId = $context->getPsrMessage()->getCorrelationId(); + $replyTo = $context->getMessage()->getReplyTo(); if (false == $replyTo) { return; } @@ -25,18 +18,20 @@ public function onPostReceived(Context $context) /** @var Result $result */ $result = $context->getResult(); if (false == $result instanceof Result) { - throw new \LogicException('To send a reply an instance of Result class has to returned from a Processor.'); + return; } if (false == $result->getReply()) { - throw new \LogicException('To send a reply the Result must contain a reply message.'); + return; } + $correlationId = $context->getMessage()->getCorrelationId(); $replyMessage = clone $result->getReply(); $replyMessage->setCorrelationId($correlationId); - $replyQueue = $context->getPsrContext()->createQueue($replyTo); + $replyQueue = $context->getContext()->createQueue($replyTo); - $context->getPsrContext()->createProducer()->send($replyQueue, $replyMessage); + $context->getLogger()->debug(sprintf('[ReplyExtension] Send reply to "%s"', $replyTo)); + $context->getContext()->createProducer()->send($replyQueue, $replyMessage); } } diff --git a/pkg/enqueue/Consumption/Extension/SignalExtension.php b/pkg/enqueue/Consumption/Extension/SignalExtension.php index a3bab8500..8ea5307d5 100644 --- a/pkg/enqueue/Consumption/Extension/SignalExtension.php +++ b/pkg/enqueue/Consumption/Extension/SignalExtension.php @@ -2,113 +2,99 @@ namespace Enqueue\Consumption\Extension; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\Context\Start; use Enqueue\Consumption\Exception\LogicException; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\PostConsumeExtensionInterface; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; +use Enqueue\Consumption\PreConsumeExtensionInterface; +use Enqueue\Consumption\StartExtensionInterface; use Psr\Log\LoggerInterface; -class SignalExtension implements ExtensionInterface +class SignalExtension implements StartExtensionInterface, PreConsumeExtensionInterface, PostMessageReceivedExtensionInterface, PostConsumeExtensionInterface { - use EmptyExtensionTrait; - /** * @var bool */ protected $interruptConsumption = false; /** - * @var LoggerInterface + * @var LoggerInterface|null */ protected $logger; - /** - * {@inheritdoc} - */ - public function onStart(Context $context) + public function onStart(Start $context): void { if (false == extension_loaded('pcntl')) { throw new LogicException('The pcntl extension is required in order to catch signals.'); } - pcntl_signal(SIGTERM, [$this, 'handleSignal']); - pcntl_signal(SIGQUIT, [$this, 'handleSignal']); - pcntl_signal(SIGINT, [$this, 'handleSignal']); + pcntl_async_signals(true); + pcntl_signal(\SIGTERM, [$this, 'handleSignal']); + pcntl_signal(\SIGQUIT, [$this, 'handleSignal']); + pcntl_signal(\SIGINT, [$this, 'handleSignal']); + + $this->logger = $context->getLogger(); $this->interruptConsumption = false; } - /** - * @param Context $context - */ - public function onBeforeReceive(Context $context) + public function onPreConsume(PreConsume $context): void { $this->logger = $context->getLogger(); - pcntl_signal_dispatch(); - - $this->interruptExecutionIfNeeded($context); - } - - /** - * {@inheritdoc} - */ - public function onPreReceived(Context $context) - { - $this->interruptExecutionIfNeeded($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * {@inheritdoc} - */ - public function onPostReceived(Context $context) + public function onPostMessageReceived(PostMessageReceived $context): void { - pcntl_signal_dispatch(); - - $this->interruptExecutionIfNeeded($context); - } - - /** - * {@inheritdoc} - */ - public function onIdle(Context $context) - { - pcntl_signal_dispatch(); - - $this->interruptExecutionIfNeeded($context); + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); + } } - /** - * @param Context $context - */ - public function interruptExecutionIfNeeded(Context $context) + public function onPostConsume(PostConsume $context): void { - if (false == $context->isExecutionInterrupted() && $this->interruptConsumption) { - $this->logger->debug('[SignalExtension] Interrupt execution'); - $context->setExecutionInterrupted($this->interruptConsumption); - - $this->interruptConsumption = false; + if ($this->shouldBeStopped($context->getLogger())) { + $context->interruptExecution(); } } - /** - * @param int $signal - */ - public function handleSignal($signal) + public function handleSignal(int $signal): void { if ($this->logger) { $this->logger->debug(sprintf('[SignalExtension] Caught signal: %s', $signal)); } switch ($signal) { - case SIGTERM: // 15 : supervisor default stop - case SIGQUIT: // 3 : kill -s QUIT - case SIGINT: // 2 : ctrl+c - $this->logger->debug('[SignalExtension] Interrupt consumption'); + case \SIGTERM: // 15 : supervisor default stop + case \SIGQUIT: // 3 : kill -s QUIT + case \SIGINT: // 2 : ctrl+c + if ($this->logger) { + $this->logger->debug('[SignalExtension] Interrupt consumption'); + } + $this->interruptConsumption = true; break; default: break; } } + + private function shouldBeStopped(LoggerInterface $logger): bool + { + if ($this->interruptConsumption) { + $logger->debug('[SignalExtension] Interrupt execution'); + + $this->interruptConsumption = false; + + return true; + } + + return false; + } } diff --git a/pkg/enqueue/Consumption/ExtensionInterface.php b/pkg/enqueue/Consumption/ExtensionInterface.php index d05109450..326a98f0d 100644 --- a/pkg/enqueue/Consumption/ExtensionInterface.php +++ b/pkg/enqueue/Consumption/ExtensionInterface.php @@ -2,56 +2,6 @@ namespace Enqueue\Consumption; -interface ExtensionInterface +interface ExtensionInterface extends StartExtensionInterface, PreSubscribeExtensionInterface, PreConsumeExtensionInterface, MessageReceivedExtensionInterface, PostMessageReceivedExtensionInterface, MessageResultExtensionInterface, ProcessorExceptionExtensionInterface, PostConsumeExtensionInterface, EndExtensionInterface, InitLoggerExtensionInterface { - /** - * Executed only once at the very begining of the consumption. - * At this stage the context does not contain processor, consumer and queue. - * - * @param Context $context - */ - public function onStart(Context $context); - - /** - * Executed at every new cycle before we asked a broker for a new message. - * At this stage the context already contains processor, consumer and queue. - * The consumption could be interrupted at this step. - * - * @param Context $context - */ - public function onBeforeReceive(Context $context); - - /** - * Executed when a new message is received from a broker but before it was passed to processor - * The context contains a message. - * The extension may set a status. - * The consumption could be interrupted at this step but it will done only after the message is processed. - * - * @param Context $context - */ - public function onPreReceived(Context $context); - - /** - * Executed when a message is processed by a processor. - * The context contains a message status and could be changed - * The consumption could be interrupted at this step but it will done only after the message is processed. - * - * @param Context $context - */ - public function onPostReceived(Context $context); - - /** - * Called each time at the end of the cycle if nothing was done. - * - * @param Context $context - */ - public function onIdle(Context $context); - - /** - * Called when the consumption was interrupted by an extension or exception - * In case of exception it will be present in the context. - * - * @param Context $context - */ - public function onInterrupted(Context $context); } diff --git a/pkg/enqueue/Consumption/FallbackSubscriptionConsumer.php b/pkg/enqueue/Consumption/FallbackSubscriptionConsumer.php new file mode 100644 index 000000000..15e2f273b --- /dev/null +++ b/pkg/enqueue/Consumption/FallbackSubscriptionConsumer.php @@ -0,0 +1,109 @@ +subscribers = []; + } + + public function consume(int $timeoutMs = 0): void + { + if (!$subscriberCount = \count($this->subscribers)) { + throw new \LogicException('No subscribers'); + } + + $timeout = $timeoutMs / 1000; + $endAt = microtime(true) + $timeout; + + while (true) { + /** + * @var string + * @var Consumer $consumer + * @var callable $processor + */ + foreach ($this->subscribers as $queueName => list($consumer, $callback)) { + $message = 1 === $subscriberCount ? $consumer->receive($timeoutMs) : $consumer->receiveNoWait(); + + if ($message) { + if (false === call_user_func($callback, $message, $consumer)) { + return; + } + } elseif (1 !== $subscriberCount) { + if ($timeout && microtime(true) >= $endAt) { + return; + } + + $this->idleTime && usleep($this->idleTime); + } + + if ($timeout && microtime(true) >= $endAt) { + return; + } + } + } + } + + public function subscribe(Consumer $consumer, callable $callback): void + { + $queueName = $consumer->getQueue()->getQueueName(); + if (array_key_exists($queueName, $this->subscribers)) { + if ($this->subscribers[$queueName][0] === $consumer && $this->subscribers[$queueName][1] === $callback) { + return; + } + + throw new \InvalidArgumentException(sprintf('There is a consumer subscribed to queue: "%s"', $queueName)); + } + + $this->subscribers[$queueName] = [$consumer, $callback]; + } + + public function unsubscribe(Consumer $consumer): void + { + if (false == array_key_exists($consumer->getQueue()->getQueueName(), $this->subscribers)) { + return; + } + + if ($this->subscribers[$consumer->getQueue()->getQueueName()][0] !== $consumer) { + return; + } + + unset($this->subscribers[$consumer->getQueue()->getQueueName()]); + } + + public function unsubscribeAll(): void + { + $this->subscribers = []; + } + + public function getIdleTime(): int + { + return $this->idleTime; + } + + /** + * The time in milliseconds the consumer waits if no message has been received. + */ + public function setIdleTime(int $idleTime): void + { + $this->idleTime = $idleTime; + } +} diff --git a/pkg/enqueue/Consumption/InitLoggerExtensionInterface.php b/pkg/enqueue/Consumption/InitLoggerExtensionInterface.php new file mode 100644 index 000000000..936e32d6e --- /dev/null +++ b/pkg/enqueue/Consumption/InitLoggerExtensionInterface.php @@ -0,0 +1,14 @@ +psrContext = $psrContext; - $this->extension = $extension; - $this->idleMicroseconds = $idleMicroseconds; + $this->interopContext = $interopContext; + $this->receiveTimeout = $receiveTimeout; + + $this->staticExtension = $extension ?: new ChainExtension([]); + $this->logger = $logger ?: new NullLogger(); $this->boundProcessors = []; + array_walk($boundProcessors, function (BoundProcessor $processor) { + $this->boundProcessors[] = $processor; + }); + + $this->fallbackSubscriptionConsumer = new FallbackSubscriptionConsumer(); } - /** - * @return PsrContext - */ - public function getPsrContext() + public function setReceiveTimeout(int $timeout): void { - return $this->psrContext; + $this->receiveTimeout = $timeout; } - /** - * @param Queue|string $queue - * @param Processor|callable $processor - * - * @return QueueConsumer - */ - public function bind($queue, $processor) + public function getReceiveTimeout(): int + { + return $this->receiveTimeout; + } + + public function getContext(): InteropContext + { + return $this->interopContext; + } + + public function bind($queue, Processor $processor): QueueConsumerInterface { if (is_string($queue)) { - $queue = $this->psrContext->createQueue($queue); - } - if (is_callable($processor)) { - $processor = new CallbackProcessor($processor); + $queue = $this->interopContext->createQueue($queue); } - InvalidArgumentException::assertInstanceOf($queue, Queue::class); - InvalidArgumentException::assertInstanceOf($processor, Processor::class); + InvalidArgumentException::assertInstanceOf($queue, InteropQueue::class); if (empty($queue->getQueueName())) { throw new LogicException('The queue name must be not empty.'); @@ -88,116 +112,99 @@ public function bind($queue, $processor) throw new LogicException(sprintf('The queue was already bound. Queue: %s', $queue->getQueueName())); } - $this->boundProcessors[$queue->getQueueName()] = [$queue, $processor]; + $this->boundProcessors[$queue->getQueueName()] = new BoundProcessor($queue, $processor); return $this; } - /** - * Runtime extension - is an extension or a collection of extensions which could be set on runtime. - * Here's a good example: @see LimitsExtensionsCommandTrait. - * - * @param ExtensionInterface|ChainExtension|null $runtimeExtension - * - * @throws \Exception - */ - public function consume(ExtensionInterface $runtimeExtension = null) + public function bindCallback($queue, callable $processor): QueueConsumerInterface { - /** @var Consumer[] $messageConsumers */ - $messageConsumers = []; - /** @var \Enqueue\Psr\Queue $queue */ - foreach ($this->boundProcessors as list($queue, $processor)) { - $messageConsumers[$queue->getQueueName()] = $this->psrContext->createConsumer($queue); - } + return $this->bind($queue, new CallbackProcessor($processor)); + } - $extension = $this->extension ?: new ChainExtension([]); - if ($runtimeExtension) { - $extension = new ChainExtension([$extension, $runtimeExtension]); - } + public function consume(?ExtensionInterface $runtimeExtension = null): void + { + $extension = $runtimeExtension ? + new ChainExtension([$this->staticExtension, $runtimeExtension]) : + $this->staticExtension + ; - $context = new Context($this->psrContext); - $extension->onStart($context); + $initLogger = new InitLogger($this->logger); + $extension->onInitLogger($initLogger); - $logger = $context->getLogger() ?: new NullLogger(); - $logger->info('Start consuming'); + $this->logger = $initLogger->getLogger(); - while (true) { - try { - /** @var Queue $queue */ - foreach ($this->boundProcessors as list($queue, $processor)) { - $logger->debug(sprintf('Switch to a queue %s', $queue->getQueueName())); + $startTime = (int) (microtime(true) * 1000); - $messageConsumer = $messageConsumers[$queue->getQueueName()]; + $start = new Start( + $this->interopContext, + $this->logger, + $this->boundProcessors, + $this->receiveTimeout, + $startTime + ); - $context = new Context($this->psrContext); - $context->setLogger($logger); - $context->setPsrQueue($queue); - $context->setPsrConsumer($messageConsumer); - $context->setPsrProcessor($processor); + $extension->onStart($start); - $this->doConsume($extension, $context); - } - } catch (ConsumptionInterruptedException $e) { - $logger->info(sprintf('Consuming interrupted')); + if ($start->isExecutionInterrupted()) { + $this->onEnd($extension, $startTime, $start->getExitStatus()); - $context->setExecutionInterrupted(true); + return; + } - $extension->onInterrupted($context); - $this->psrContext->close(); + $this->logger = $start->getLogger(); + $this->receiveTimeout = $start->getReceiveTimeout(); + $this->boundProcessors = $start->getBoundProcessors(); - return; - } catch (\Exception $exception) { - $context->setExecutionInterrupted(true); - $context->setException($exception); + if (empty($this->boundProcessors)) { + throw new \LogicException('There is nothing to consume. It is required to bind something before calling consume method.'); + } - try { - $this->onInterruptionByException($extension, $context); - $this->psrContext->close(); - } catch (\Exception $e) { - // for some reason finally does not work here on php5.5 - $this->psrContext->close(); + /** @var Consumer[] $consumers */ + $consumers = []; + foreach ($this->boundProcessors as $queueName => $boundProcessor) { + $queue = $boundProcessor->getQueue(); - throw $e; - } - } + $consumers[$queue->getQueueName()] = $this->interopContext->createConsumer($queue); } - } - /** - * @param ExtensionInterface $extension - * @param Context $context - * - * @throws ConsumptionInterruptedException - * - * @return bool - */ - protected function doConsume(ExtensionInterface $extension, Context $context) - { - $processor = $context->getPsrProcessor(); - $consumer = $context->getPsrConsumer(); - $logger = $context->getLogger(); + try { + $subscriptionConsumer = $this->interopContext->createSubscriptionConsumer(); + } catch (SubscriptionConsumerNotSupportedException $e) { + $subscriptionConsumer = $this->fallbackSubscriptionConsumer; + } - $extension->onBeforeReceive($context); + $receivedMessagesCount = 0; + $interruptExecution = false; - if ($context->isExecutionInterrupted()) { - throw new ConsumptionInterruptedException(); - } + $callback = function (InteropMessage $message, Consumer $consumer) use (&$receivedMessagesCount, &$interruptExecution, $extension) { + ++$receivedMessagesCount; - if ($message = $consumer->receive($timeout = 1)) { - $logger->info('Message received'); - $logger->debug('Headers: {headers}', ['headers' => new VarExport($message->getHeaders())]); - $logger->debug('Properties: {properties}', ['properties' => new VarExport($message->getProperties())]); - $logger->debug('Payload: {payload}', ['payload' => new VarExport($message->getBody())]); + $receivedAt = (int) (microtime(true) * 1000); + $queue = $consumer->getQueue(); + if (false == array_key_exists($queue->getQueueName(), $this->boundProcessors)) { + throw new \LogicException(sprintf('The processor for the queue "%s" could not be found.', $queue->getQueueName())); + } - $context->setPsrMessage($message); + $processor = $this->boundProcessors[$queue->getQueueName()]->getProcessor(); - $extension->onPreReceived($context); - if (!$context->getResult()) { - $result = $processor->process($message, $this->psrContext); - $context->setResult($result); + $messageReceived = new MessageReceived($this->interopContext, $consumer, $message, $processor, $receivedAt, $this->logger); + $extension->onMessageReceived($messageReceived); + $result = $messageReceived->getResult(); + $processor = $messageReceived->getProcessor(); + if (null === $result) { + try { + $result = $processor->process($message, $this->interopContext); + } catch (\Exception|\Throwable $e) { + $result = $this->onProcessorException($extension, $consumer, $message, $e, $receivedAt); + } } - switch ($context->getResult()) { + $messageResult = new MessageResult($this->interopContext, $consumer, $message, $result, $receivedAt, $this->logger); + $extension->onResult($messageResult); + $result = $messageResult->getResult(); + + switch ($result) { case Result::ACK: $consumer->acknowledge($message); break; @@ -207,64 +214,119 @@ protected function doConsume(ExtensionInterface $extension, Context $context) case Result::REQUEUE: $consumer->reject($message, true); break; + case Result::ALREADY_ACKNOWLEDGED: + break; default: - throw new \LogicException(sprintf('Status is not supported: %s', $context->getResult())); + throw new \LogicException(sprintf('Status is not supported: %s', $result)); + } + + $postMessageReceived = new PostMessageReceived($this->interopContext, $consumer, $message, $result, $receivedAt, $this->logger); + $extension->onPostMessageReceived($postMessageReceived); + + if ($postMessageReceived->isExecutionInterrupted()) { + $interruptExecution = true; + + return false; + } + + return true; + }; + + foreach ($consumers as $queueName => $consumer) { + /* @var Consumer $consumer */ + + $preSubscribe = new PreSubscribe( + $this->interopContext, + $this->boundProcessors[$queueName]->getProcessor(), + $consumer, + $this->logger + ); + + $extension->onPreSubscribe($preSubscribe); + + $subscriptionConsumer->subscribe($consumer, $callback); + } + + $cycle = 1; + while (true) { + $receivedMessagesCount = 0; + $interruptExecution = false; + + $preConsume = new PreConsume($this->interopContext, $subscriptionConsumer, $this->logger, $cycle, $this->receiveTimeout, $startTime); + $extension->onPreConsume($preConsume); + + if ($preConsume->isExecutionInterrupted()) { + $this->onEnd($extension, $startTime, $preConsume->getExitStatus(), $subscriptionConsumer); + + return; } - $logger->info(sprintf('Message processed: %s', $context->getResult())); + $subscriptionConsumer->consume($this->receiveTimeout); - $extension->onPostReceived($context); - } else { - $logger->info(sprintf('Idle')); + $postConsume = new PostConsume($this->interopContext, $subscriptionConsumer, $receivedMessagesCount, $cycle, $startTime, $this->logger); + $extension->onPostConsume($postConsume); - usleep($this->idleMicroseconds); - $extension->onIdle($context); + if ($interruptExecution || $postConsume->isExecutionInterrupted()) { + $this->onEnd($extension, $startTime, $postConsume->getExitStatus(), $subscriptionConsumer); + + return; + } + + ++$cycle; } + } + + /** + * @internal + */ + public function setFallbackSubscriptionConsumer(SubscriptionConsumer $fallbackSubscriptionConsumer): void + { + $this->fallbackSubscriptionConsumer = $fallbackSubscriptionConsumer; + } + + private function onEnd(ExtensionInterface $extension, int $startTime, ?int $exitStatus = null, ?SubscriptionConsumer $subscriptionConsumer = null): void + { + $endTime = (int) (microtime(true) * 1000); + + $endContext = new End($this->interopContext, $startTime, $endTime, $this->logger, $exitStatus); + $extension->onEnd($endContext); - if ($context->isExecutionInterrupted()) { - throw new ConsumptionInterruptedException(); + if ($subscriptionConsumer) { + $subscriptionConsumer->unsubscribeAll(); } } /** - * @param ExtensionInterface $extension - * @param Context $context + * The logic is similar to one in Symfony's ExceptionListener::onKernelException(). * - * @throws \Exception + * https://github.com/symfony/symfony/blob/cbe289517470eeea27162fd2d523eb29c95f775f/src/Symfony/Component/HttpKernel/EventListener/ExceptionListener.php#L77 */ - protected function onInterruptionByException(ExtensionInterface $extension, Context $context) + private function onProcessorException(ExtensionInterface $extension, Consumer $consumer, Message $message, \Throwable $exception, int $receivedAt) { - $logger = $context->getLogger(); - $logger->error(sprintf('Consuming interrupted by exception')); - - $exception = $context->getException(); + $processorException = new ProcessorException($this->interopContext, $consumer, $message, $exception, $receivedAt, $this->logger); try { - $extension->onInterrupted($context); + $extension->onProcessorException($processorException); + + $result = $processorException->getResult(); + if (null === $result) { + throw $exception; + } + + return $result; } catch (\Exception $e) { - // logic is similar to one in Symfony's ExceptionListener::onKernelException - $logger->error(sprintf( - 'Exception thrown when handling an exception (%s: %s at %s line %s)', - get_class($e), - $e->getMessage(), - $e->getFile(), - $e->getLine() - )); - - $wrapper = $e; - while ($prev = $wrapper->getPrevious()) { + $prev = $e; + do { if ($exception === $wrapper = $prev) { throw $e; } - } + } while ($prev = $wrapper->getPrevious()); - $prev = new \ReflectionProperty('Exception', 'previous'); + $prev = new \ReflectionProperty($wrapper instanceof \Exception ? \Exception::class : \Error::class, 'previous'); $prev->setAccessible(true); $prev->setValue($wrapper, $exception); throw $e; } - - throw $exception; } } diff --git a/pkg/enqueue/Consumption/QueueConsumerInterface.php b/pkg/enqueue/Consumption/QueueConsumerInterface.php new file mode 100644 index 000000000..ee2565252 --- /dev/null +++ b/pkg/enqueue/Consumption/QueueConsumerInterface.php @@ -0,0 +1,40 @@ +status = (string) $status; @@ -72,17 +70,14 @@ public function getReason() } /** - * @return PsrMessage|null + * @return InteropMessage|null */ public function getReply() { return $this->reply; } - /** - * @param PsrMessage|null $reply - */ - public function setReply(PsrMessage $reply = null) + public function setReply(?InteropMessage $reply = null) { $this->reply = $reply; } @@ -90,42 +85,44 @@ public function setReply(PsrMessage $reply = null) /** * @param string $reason * - * @return Result + * @return static */ public static function ack($reason = '') { - return new static(self::ACK, $reason); + return new self(self::ACK, $reason); } /** * @param string $reason * - * @return Result + * @return static */ public static function reject($reason) { - return new static(self::REJECT, $reason); + return new self(self::REJECT, $reason); } /** * @param string $reason * - * @return Result + * @return static */ public static function requeue($reason = '') { - return new static(self::REQUEUE, $reason); + return new self(self::REQUEUE, $reason); } /** - * @param PsrMessage $replyMessage + * @param string $status * @param string|null $reason * - * @return Result + * @return static */ - public static function reply(PsrMessage $replyMessage, $reason = '') + public static function reply(InteropMessage $replyMessage, $status = self::ACK, $reason = null) { - $result = static::ack($reason); + $status = null === $status ? self::ACK : $status; + + $result = new self($status, $reason); $result->setReply($replyMessage); return $result; diff --git a/pkg/enqueue/Consumption/StartExtensionInterface.php b/pkg/enqueue/Consumption/StartExtensionInterface.php new file mode 100644 index 000000000..98571061c --- /dev/null +++ b/pkg/enqueue/Consumption/StartExtensionInterface.php @@ -0,0 +1,13 @@ +services = $services; + } + + public function get($id) + { + if (false == $this->has($id)) { + throw new NotFoundException(sprintf('The service "%s" not found.', $id)); + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} diff --git a/pkg/enqueue/Container/NotFoundException.php b/pkg/enqueue/Container/NotFoundException.php new file mode 100644 index 000000000..fcc3386e6 --- /dev/null +++ b/pkg/enqueue/Container/NotFoundException.php @@ -0,0 +1,9 @@ +doctrine = $doctrine; + $this->fallbackFactory = $fallbackFactory; + } + + public function create($config): ConnectionFactory + { + if (is_string($config)) { + $config = ['dsn' => $config]; + } + + if (false == is_array($config)) { + throw new \InvalidArgumentException('The config must be either array or DSN string.'); + } + + if (false == array_key_exists('dsn', $config)) { + throw new \InvalidArgumentException('The config must have dsn key set.'); + } + + $dsn = Dsn::parseFirst($config['dsn']); + + if ('doctrine' === $dsn->getScheme()) { + $config = $dsn->getQuery(); + $config['connection_name'] = $dsn->getHost(); + + return new ManagerRegistryConnectionFactory($this->doctrine, $config); + } + + return $this->fallbackFactory->create($config); + } +} diff --git a/pkg/enqueue/Doctrine/DoctrineDriverFactory.php b/pkg/enqueue/Doctrine/DoctrineDriverFactory.php new file mode 100644 index 000000000..aab6489aa --- /dev/null +++ b/pkg/enqueue/Doctrine/DoctrineDriverFactory.php @@ -0,0 +1,41 @@ +fallbackFactory = $fallbackFactory; + } + + public function create(ConnectionFactory $factory, Config $config, RouteCollection $collection): DriverInterface + { + $dsn = $config->getTransportOption('dsn'); + + if (empty($dsn)) { + throw new \LogicException('This driver factory relies on dsn option from transport config. The option is empty or not set.'); + } + + $dsn = Dsn::parseFirst($dsn); + + if ('doctrine' === $dsn->getScheme()) { + return new DbalDriver($factory->createContext(), $config, $collection); + } + + return $this->fallbackFactory->create($factory, $config, $collection); + } +} diff --git a/pkg/enqueue/Doctrine/DoctrineSchemaCompilerPass.php b/pkg/enqueue/Doctrine/DoctrineSchemaCompilerPass.php new file mode 100644 index 000000000..0eb378470 --- /dev/null +++ b/pkg/enqueue/Doctrine/DoctrineSchemaCompilerPass.php @@ -0,0 +1,39 @@ +hasDefinition('doctrine')) { + return; + } + + foreach ($container->getParameter('enqueue.transports') as $name) { + $diUtils = DiUtils::create(TransportFactory::MODULE, $name); + + $container->register($diUtils->format('connection_factory_factory.outer'), DoctrineConnectionFactoryFactory::class) + ->setDecoratedService($diUtils->format('connection_factory_factory'), $diUtils->format('connection_factory_factory.inner')) + ->addArgument(new Reference('doctrine')) + ->addArgument(new Reference($diUtils->format('connection_factory_factory.inner'))) + ; + } + + foreach ($container->getParameter('enqueue.clients') as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + + $container->register($diUtils->format('driver_factory.outer'), DoctrineDriverFactory::class) + ->setDecoratedService($diUtils->format('driver_factory'), $diUtils->format('driver_factory.inner')) + ->addArgument(new Reference($diUtils->format('driver_factory.inner'))) + ; + } + } +} diff --git a/pkg/enqueue/ProcessorRegistryInterface.php b/pkg/enqueue/ProcessorRegistryInterface.php new file mode 100644 index 000000000..5306c3035 --- /dev/null +++ b/pkg/enqueue/ProcessorRegistryInterface.php @@ -0,0 +1,12 @@ +Supporting Enqueue + +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + # Message Queue. [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/enqueue.png?branch=master)](https://travis-ci.org/php-enqueue/enqueue) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/enqueue/ci.yml?branch=master)](https://github.com/php-enqueue/enqueue/actions?query=workflow%3ACI) [![Total Downloads](https://poser.pugx.org/enqueue/enqueue/d/total.png)](https://packagist.org/packages/enqueue/enqueue) [![Latest Stable Version](https://poser.pugx.org/enqueue/enqueue/version.png)](https://packagist.org/packages/enqueue/enqueue) - -It contains advanced features build on top of a transport component. + +It contains advanced features build on top of a transport component. Client component kind of plug and play things or consumption component that simplify message processing a lot. -Read more about it in documentation. +Read more about it in documentation. ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/enqueue/Resources.php b/pkg/enqueue/Resources.php new file mode 100644 index 000000000..4c500006f --- /dev/null +++ b/pkg/enqueue/Resources.php @@ -0,0 +1,211 @@ + [ + * schemes => [schemes strings], + * package => package name, + * ]. + * + * @var array + */ + private static $knownConnections; + + private function __construct() + { + } + + public static function getAvailableConnections(): array + { + $map = self::getKnownConnections(); + + $availableMap = []; + foreach ($map as $connectionClass => $item) { + if (\class_exists($connectionClass)) { + $availableMap[$connectionClass] = $item; + } + } + + return $availableMap; + } + + public static function getKnownSchemes(): array + { + $map = self::getKnownConnections(); + + $schemes = []; + foreach ($map as $connectionClass => $item) { + foreach ($item['schemes'] as $scheme) { + $schemes[$scheme] = $connectionClass; + } + } + + return $schemes; + } + + public static function getAvailableSchemes(): array + { + $map = self::getAvailableConnections(); + + $schemes = []; + foreach ($map as $connectionClass => $item) { + foreach ($item['schemes'] as $scheme) { + $schemes[$scheme] = $connectionClass; + } + } + + return $schemes; + } + + public static function getKnownConnections(): array + { + if (null === self::$knownConnections) { + $map = []; + + $map[FsConnectionFactory::class] = [ + 'schemes' => ['file'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/fs', + ]; + $map[AmqpBunnyConnectionFactory::class] = [ + 'schemes' => ['amqp'], + 'supportedSchemeExtensions' => ['bunny'], + 'package' => 'enqueue/amqp-bunny', + ]; + $map[AmqpExtConnectionFactory::class] = [ + 'schemes' => ['amqp', 'amqps'], + 'supportedSchemeExtensions' => ['ext'], + 'package' => 'enqueue/amqp-ext', + ]; + $map[AmqpLibConnectionFactory::class] = [ + 'schemes' => ['amqp', 'amqps'], + 'supportedSchemeExtensions' => ['lib'], + 'package' => 'enqueue/amqp-lib', + ]; + + $map[DbalConnectionFactory::class] = [ + 'schemes' => [ + 'db2', + 'ibm-db2', + 'mssql', + 'sqlsrv', + 'mysql', + 'mysql2', + 'mysql', + 'pgsql', + 'postgres', + 'sqlite', + 'sqlite3', + 'sqlite', + ], + 'supportedSchemeExtensions' => ['pdo'], + 'package' => 'enqueue/dbal', + ]; + + $map[NullConnectionFactory::class] = [ + 'schemes' => ['null'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/null', + ]; + $map[GearmanConnectionFactory::class] = [ + 'schemes' => ['gearman'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/gearman', + ]; + $map[PheanstalkConnectionFactory::class] = [ + 'schemes' => ['beanstalk'], + 'supportedSchemeExtensions' => ['pheanstalk'], + 'package' => 'enqueue/pheanstalk', + ]; + $map[RdKafkaConnectionFactory::class] = [ + 'schemes' => ['kafka', 'rdkafka'], + 'supportedSchemeExtensions' => ['rdkafka'], + 'package' => 'enqueue/rdkafka', + ]; + $map[RedisConnectionFactory::class] = [ + 'schemes' => ['redis', 'rediss'], + 'supportedSchemeExtensions' => ['predis', 'phpredis'], + 'package' => 'enqueue/redis', + ]; + $map[StompConnectionFactory::class] = [ + 'schemes' => ['stomp'], + 'supportedSchemeExtensions' => ['rabbitmq'], + 'package' => 'enqueue/stomp', ]; + $map[SqsConnectionFactory::class] = [ + 'schemes' => ['sqs'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/sqs', ]; + $map[SnsConnectionFactory::class] = [ + 'schemes' => ['sns'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/sns', ]; + $map[SnsQsConnectionFactory::class] = [ + 'schemes' => ['snsqs'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/snsqs', ]; + $map[GpsConnectionFactory::class] = [ + 'schemes' => ['gps'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/gps', ]; + $map[MongodbConnectionFactory::class] = [ + 'schemes' => ['mongodb'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/mongodb', + ]; + $map[WampConnectionFactory::class] = [ + 'schemes' => ['wamp', 'ws'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/wamp', + ]; + + self::$knownConnections = $map; + } + + return self::$knownConnections; + } + + public static function addConnection(string $connectionFactoryClass, array $schemes, array $extensions, string $package): void + { + if (\class_exists($connectionFactoryClass)) { + if (false == (new \ReflectionClass($connectionFactoryClass))->implementsInterface(ConnectionFactory::class)) { + throw new \InvalidArgumentException(\sprintf('The connection factory class "%s" must implement "%s" interface.', $connectionFactoryClass, ConnectionFactory::class)); + } + } + + if (empty($schemes)) { + throw new \InvalidArgumentException('Schemes could not be empty.'); + } + if (empty($package)) { + throw new \InvalidArgumentException('Package name could not be empty.'); + } + + self::getKnownConnections(); + self::$knownConnections[$connectionFactoryClass] = [ + 'schemes' => $schemes, + 'supportedSchemeExtensions' => $extensions, + 'package' => $package, + ]; + } +} diff --git a/pkg/enqueue/Router/Recipient.php b/pkg/enqueue/Router/Recipient.php index 892c0e511..d2f668f42 100644 --- a/pkg/enqueue/Router/Recipient.php +++ b/pkg/enqueue/Router/Recipient.php @@ -2,8 +2,8 @@ namespace Enqueue\Router; -use Enqueue\Psr\Destination; -use Enqueue\Psr\Message; +use Interop\Queue\Destination; +use Interop\Queue\Message as InteropMessage; class Recipient { @@ -13,15 +13,11 @@ class Recipient private $destination; /** - * @var Message + * @var InteropMessage */ private $message; - /** - * @param Destination $destination - * @param Message $message - */ - public function __construct(Destination $destination, Message $message) + public function __construct(Destination $destination, InteropMessage $message) { $this->destination = $destination; $this->message = $message; @@ -36,7 +32,7 @@ public function getDestination() } /** - * @return Message + * @return InteropMessage */ public function getMessage() { diff --git a/pkg/enqueue/Router/RecipientListRouterInterface.php b/pkg/enqueue/Router/RecipientListRouterInterface.php index de7669bae..6bb950fdc 100644 --- a/pkg/enqueue/Router/RecipientListRouterInterface.php +++ b/pkg/enqueue/Router/RecipientListRouterInterface.php @@ -2,14 +2,12 @@ namespace Enqueue\Router; -use Enqueue\Psr\Message; +use Interop\Queue\Message as InteropMessage; interface RecipientListRouterInterface { /** - * @param Message $message - * * @return \Traversable|Recipient[] */ - public function route(Message $message); + public function route(InteropMessage $message); } diff --git a/pkg/enqueue/Router/RouteRecipientListProcessor.php b/pkg/enqueue/Router/RouteRecipientListProcessor.php index 1cc2daef2..22488e33f 100644 --- a/pkg/enqueue/Router/RouteRecipientListProcessor.php +++ b/pkg/enqueue/Router/RouteRecipientListProcessor.php @@ -2,9 +2,9 @@ namespace Enqueue\Router; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; +use Interop\Queue\Context; +use Interop\Queue\Message as InteropMessage; +use Interop\Queue\Processor; class RouteRecipientListProcessor implements Processor { @@ -13,18 +13,12 @@ class RouteRecipientListProcessor implements Processor */ private $router; - /** - * @param RecipientListRouterInterface $router - */ public function __construct(RecipientListRouterInterface $router) { $this->router = $router; } - /** - * {@inheritdoc} - */ - public function process(Message $message, Context $context) + public function process(InteropMessage $message, Context $context) { $producer = $context->createProducer(); foreach ($this->router->route($message) as $recipient) { diff --git a/pkg/enqueue/Rpc/Promise.php b/pkg/enqueue/Rpc/Promise.php index b0265f115..01b47e1f6 100644 --- a/pkg/enqueue/Rpc/Promise.php +++ b/pkg/enqueue/Rpc/Promise.php @@ -2,67 +2,117 @@ namespace Enqueue\Rpc; -use Enqueue\Psr\Consumer; +use Interop\Queue\Message as InteropMessage; class Promise { /** - * @var Consumer + * @var \Closure */ - private $consumer; + private $receiveCallback; /** - * @var int + * @var \Closure */ - private $timeout; + private $receiveNoWaitCallback; + + /** + * @var \Closure + */ + private $finallyCallback; + /** - * @var string + * @var bool */ - private $correlationId; + private $deleteReplyQueue; /** - * @param Consumer $consumer - * @param string $correlationId - * @param int $timeout + * @var InteropMessage */ - public function __construct(Consumer $consumer, $correlationId, $timeout) + private $message; + + public function __construct(\Closure $receiveCallback, \Closure $receiveNoWaitCallback, \Closure $finallyCallback) { - $this->consumer = $consumer; - $this->timeout = $timeout; - $this->correlationId = $correlationId; + $this->receiveCallback = $receiveCallback; + $this->receiveNoWaitCallback = $receiveNoWaitCallback; + $this->finallyCallback = $finallyCallback; + + $this->deleteReplyQueue = true; } - public function getMessage() + /** + * Blocks until message received or timeout expired. + * + * @param int $timeout + * + * @throws TimeoutException if the wait timeout is reached + * + * @return InteropMessage + */ + public function receive($timeout = null) { - $endTime = time() + $this->timeout; + if (null == $this->message) { + try { + if ($message = $this->doReceive($this->receiveCallback, $this, $timeout)) { + $this->message = $message; + } + } finally { + call_user_func($this->finallyCallback, $this); + } + } - while (time() < $endTime) { - if ($message = $this->consumer->receive($this->timeout)) { - if ($message->getCorrelationId() === $this->correlationId) { - $this->consumer->acknowledge($message); + return $this->message; + } - return $message; - } - $this->consumer->reject($message, true); + /** + * Non blocking function. Returns message or null. + * + * @return InteropMessage|null + */ + public function receiveNoWait() + { + if (null == $this->message) { + if ($message = $this->doReceive($this->receiveNoWaitCallback, $this)) { + $this->message = $message; + + call_user_func($this->finallyCallback, $this); } } - throw new \LogicException(sprintf('Time outed without receiving reply message. Timeout: %s, CorrelationId: %s', $this->timeout, $this->correlationId)); + return $this->message; } /** - * @param int $timeout + * On TRUE deletes reply queue after getMessage call. + * + * @param bool $delete */ - public function setTimeout($timeout) + public function setDeleteReplyQueue($delete) { - $this->timeout = $timeout; + $this->deleteReplyQueue = (bool) $delete; } /** - * @return int + * @return bool */ - public function getTimeout() + public function isDeleteReplyQueue() { - return $this->timeout; + return $this->deleteReplyQueue; + } + + /** + * @param array $args + * + * @return InteropMessage + */ + private function doReceive(\Closure $cb, ...$args) + { + $message = call_user_func_array($cb, $args); + + if (null !== $message && false == $message instanceof InteropMessage) { + throw new \RuntimeException(sprintf('Expected "%s" but got: "%s"', InteropMessage::class, is_object($message) ? $message::class : gettype($message))); + } + + return $message; } } diff --git a/pkg/enqueue/Rpc/RpcClient.php b/pkg/enqueue/Rpc/RpcClient.php index fad0bcd69..bd3d7cedb 100644 --- a/pkg/enqueue/Rpc/RpcClient.php +++ b/pkg/enqueue/Rpc/RpcClient.php @@ -2,10 +2,10 @@ namespace Enqueue\Rpc; -use Enqueue\Psr\Context; -use Enqueue\Psr\Destination; -use Enqueue\Psr\Message; use Enqueue\Util\UUID; +use Interop\Queue\Context; +use Interop\Queue\Destination; +use Interop\Queue\Message as InteropMessage; class RpcClient { @@ -15,43 +15,45 @@ class RpcClient private $context; /** - * @param Context $context + * @var RpcFactory */ - public function __construct(Context $context) + private $rpcFactory; + + public function __construct(Context $context, ?RpcFactory $promiseFactory = null) { $this->context = $context; + $this->rpcFactory = $promiseFactory ?: new RpcFactory($context); } /** - * @param Destination $destination - * @param Message $message - * @param $timeout + * @param int $timeout + * + * @throws TimeoutException if the wait timeout is reached * - * @return Message + * @return InteropMessage */ - public function call(Destination $destination, Message $message, $timeout) + public function call(Destination $destination, InteropMessage $message, $timeout) { - return $this->callAsync($destination, $message, $timeout)->getMessage(); + return $this->callAsync($destination, $message, $timeout)->receive(); } /** - * @param Destination $destination - * @param Message $message - * @param $timeout + * @param int $timeout * * @return Promise */ - public function callAsync(Destination $destination, Message $message, $timeout) + public function callAsync(Destination $destination, InteropMessage $message, $timeout) { if ($timeout < 1) { throw new \InvalidArgumentException(sprintf('Timeout must be positive not zero integer. Got %s', $timeout)); } - if ($message->getReplyTo()) { - $replyQueue = $this->context->createQueue($message->getReplyTo()); - } else { - $replyQueue = $this->context->createTemporaryQueue(); - $message->setReplyTo($replyQueue->getQueueName()); + $deleteReplyQueue = false; + $replyTo = $message->getReplyTo(); + + if (false == $replyTo) { + $message->setReplyTo($replyTo = $this->rpcFactory->createReplyTo()); + $deleteReplyQueue = true; } if (false == $message->getCorrelationId()) { @@ -60,10 +62,9 @@ public function callAsync(Destination $destination, Message $message, $timeout) $this->context->createProducer()->send($destination, $message); - return new Promise( - $this->context->createConsumer($replyQueue), - $message->getCorrelationId(), - $timeout - ); + $promise = $this->rpcFactory->createPromise($replyTo, $message->getCorrelationId(), $timeout); + $promise->setDeleteReplyQueue($deleteReplyQueue); + + return $promise; } } diff --git a/pkg/enqueue/Rpc/RpcFactory.php b/pkg/enqueue/Rpc/RpcFactory.php new file mode 100644 index 000000000..9100babd3 --- /dev/null +++ b/pkg/enqueue/Rpc/RpcFactory.php @@ -0,0 +1,84 @@ +context = $context; + } + + /** + * @param string $replyTo + * @param string $correlationId + * @param int $timeout + * + * @return Promise + */ + public function createPromise($replyTo, $correlationId, $timeout) + { + $replyQueue = $this->context->createQueue($replyTo); + + $receive = function (Promise $promise, $promiseTimeout) use ($replyQueue, $timeout, $correlationId) { + $runTimeout = $promiseTimeout ?: $timeout; + $endTime = time() + ((int) ($runTimeout / 1000)); + $consumer = $this->context->createConsumer($replyQueue); + + do { + if ($message = $consumer->receive($runTimeout)) { + if ($message->getCorrelationId() === $correlationId) { + $consumer->acknowledge($message); + + return $message; + } + + $consumer->reject($message, true); + } + } while (time() < $endTime); + + throw TimeoutException::create($runTimeout, $correlationId); + }; + + $receiveNoWait = function () use ($replyQueue, $correlationId) { + static $consumer; + + if (null === $consumer) { + $consumer = $this->context->createConsumer($replyQueue); + } + + if ($message = $consumer->receiveNoWait()) { + if ($message->getCorrelationId() === $correlationId) { + $consumer->acknowledge($message); + + return $message; + } + + $consumer->reject($message, true); + } + }; + + $finally = function (Promise $promise) use ($replyQueue) { + if ($promise->isDeleteReplyQueue() && method_exists($this->context, 'deleteQueue')) { + $this->context->deleteQueue($replyQueue); + } + }; + + return new Promise($receive, $receiveNoWait, $finally); + } + + /** + * @return string + */ + public function createReplyTo() + { + return $this->context->createTemporaryQueue()->getQueueName(); + } +} diff --git a/pkg/enqueue/Rpc/TimeoutException.php b/pkg/enqueue/Rpc/TimeoutException.php new file mode 100644 index 000000000..a7f68b967 --- /dev/null +++ b/pkg/enqueue/Rpc/TimeoutException.php @@ -0,0 +1,17 @@ +container = $container; + $this->defaultClient = $defaultClient; + $this->queueConsumerIdPattern = $queueConsumerIdPattern; + $this->driverIdPattern = $driverIdPattern; + $this->processorIdPattern = $processorIdPatter; + + parent::__construct(); + } + + protected function configure(): void + { + $this->configureLimitsExtensions(); + $this->configureSetupBrokerExtension(); + $this->configureQueueConsumerOptions(); + $this->configureLoggerExtension(); + + $this + ->setAliases(['enq:c']) + ->setDescription('A client\'s worker that processes messages. '. + 'By default it connects to default queue. '. + 'It select an appropriate message processor based on a message headers') + ->addArgument('client-queue-names', InputArgument::IS_ARRAY, 'Queues to consume messages from') + ->addOption('skip', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Queues to skip consumption of messages from', []) + ->addOption('client', 'c', InputOption::VALUE_OPTIONAL, 'The client to consume messages from.', $this->defaultClient) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $client = $input->getOption('client'); + + try { + $consumer = $this->getQueueConsumer($client); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Client "%s" is not supported.', $client), previous: $e); + } + + $driver = $this->getDriver($client); + $processor = $this->getProcessor($client); + + $this->setQueueConsumerOptions($consumer, $input); + + $allQueues[$driver->getConfig()->getDefaultQueue()] = true; + $allQueues[$driver->getConfig()->getRouterQueue()] = true; + foreach ($driver->getRouteCollection()->all() as $route) { + if (false == $route->getQueue()) { + continue; + } + if ($route->isProcessorExternal()) { + continue; + } + + $allQueues[$route->getQueue()] = $route->isPrefixQueue(); + } + + $selectedQueues = $input->getArgument('client-queue-names'); + if (empty($selectedQueues)) { + $queues = $allQueues; + } else { + $queues = []; + foreach ($selectedQueues as $queue) { + if (false == array_key_exists($queue, $allQueues)) { + throw new \LogicException(sprintf('There is no such queue "%s". Available are "%s"', $queue, implode('", "', array_keys($allQueues)))); + } + + $queues[$queue] = $allQueues[$queue]; + } + } + + foreach ($input->getOption('skip') as $skipQueue) { + unset($queues[$skipQueue]); + } + + foreach ($queues as $queue => $prefix) { + $queue = $driver->createQueue($queue, $prefix); + $consumer->bind($queue, $processor); + } + + $runtimeExtensionChain = $this->getRuntimeExtensions($input, $output); + $exitStatusExtension = new ExitStatusExtension(); + + $consumer->consume(new ChainExtension([$runtimeExtensionChain, $exitStatusExtension])); + + return $exitStatusExtension->getExitStatus() ?? 0; + } + + protected function getRuntimeExtensions(InputInterface $input, OutputInterface $output): ExtensionInterface + { + $extensions = []; + $extensions = array_merge($extensions, $this->getLimitsExtensions($input, $output)); + + $driver = $this->getDriver($input->getOption('client')); + + if ($setupBrokerExtension = $this->getSetupBrokerExtension($input, $driver)) { + $extensions[] = $setupBrokerExtension; + } + + if ($loggerExtension = $this->getLoggerExtension($input, $output)) { + array_unshift($extensions, $loggerExtension); + } + + return new ChainExtension($extensions); + } + + private function getDriver(string $name): DriverInterface + { + return $this->container->get(sprintf($this->driverIdPattern, $name)); + } + + private function getQueueConsumer(string $name): QueueConsumerInterface + { + return $this->container->get(sprintf($this->queueConsumerIdPattern, $name)); + } + + private function getProcessor(string $name): Processor + { + return $this->container->get(sprintf($this->processorIdPattern, $name)); + } +} diff --git a/pkg/enqueue/Symfony/Client/ConsumeMessagesCommand.php b/pkg/enqueue/Symfony/Client/ConsumeMessagesCommand.php deleted file mode 100644 index 3571d79a3..000000000 --- a/pkg/enqueue/Symfony/Client/ConsumeMessagesCommand.php +++ /dev/null @@ -1,124 +0,0 @@ -consumer = $consumer; - $this->processor = $processor; - $this->queueMetaRegistry = $queueMetaRegistry; - $this->driver = $driver; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this->configureLimitsExtensions(); - $this->configureSetupBrokerExtension(); - - $this - ->setName('enqueue:consume') - ->setAliases(['enq:c']) - ->setDescription('A client\'s worker that processes messages. '. - 'By default it connects to default queue. '. - 'It select an appropriate message processor based on a message headers') - ->addArgument('client-queue-names', InputArgument::IS_ARRAY, 'Queues to consume messages from') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $queueMetas = []; - if ($clientQueueNames = $input->getArgument('client-queue-names')) { - foreach ($clientQueueNames as $clientQueueName) { - $queueMetas[] = $this->queueMetaRegistry->getQueueMeta($clientQueueName); - } - } else { - $queueMetas = $this->queueMetaRegistry->getQueuesMeta(); - } - - foreach ($queueMetas as $queueMeta) { - $queue = $this->driver->createQueue($queueMeta->getClientName()); - $this->consumer->bind($queue, $this->processor); - } - - try { - $this->consumer->consume($this->getRuntimeExtensions($input, $output)); - } finally { - $this->consumer->getPsrContext()->close(); - } - } - - /** - * @param InputInterface $input - * @param OutputInterface $output - * - * @return ChainExtension - */ - protected function getRuntimeExtensions(InputInterface $input, OutputInterface $output) - { - $extensions = [new LoggerExtension(new ConsoleLogger($output))]; - $extensions = array_merge($extensions, $this->getLimitsExtensions($input, $output)); - - if ($setupBrokerExtension = $this->getSetupBrokerExtension($input, $this->driver)) { - $extensions[] = $setupBrokerExtension; - } - - return new ChainExtension($extensions); - } -} diff --git a/pkg/enqueue/Symfony/Client/ContainerAwareProcessorRegistry.php b/pkg/enqueue/Symfony/Client/ContainerAwareProcessorRegistry.php deleted file mode 100644 index f199167dc..000000000 --- a/pkg/enqueue/Symfony/Client/ContainerAwareProcessorRegistry.php +++ /dev/null @@ -1,61 +0,0 @@ -processors = $processors; - } - - /** - * @param string $processorName - * @param string $serviceId - */ - public function set($processorName, $serviceId) - { - $this->processors[$processorName] = $serviceId; - } - - /** - * {@inheritdoc} - */ - public function get($processorName) - { - if (false == isset($this->processors[$processorName])) { - throw new \LogicException(sprintf('Processor was not found. processorName: "%s"', $processorName)); - } - - if (null === $this->container) { - throw new \LogicException('Container was not set'); - } - - $processor = $this->container->get($this->processors[$processorName]); - - if (false == $processor instanceof Processor) { - throw new \LogicException(sprintf( - 'Invalid instance of message processor. expected: "%s", got: "%s"', - Processor::class, - is_object($processor) ? get_class($processor) : gettype($processor) - )); - } - - return $processor; - } -} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPass.php new file mode 100644 index 000000000..577f15902 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPass.php @@ -0,0 +1,107 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + + $routeCollectionId = $diUtils->format('route_collection'); + if (false == $container->hasDefinition($routeCollectionId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routeCollectionId)); + } + + $collection = RouteCollection::fromArray($container->getDefinition($routeCollectionId)->getArgument(0)); + + $this->exclusiveCommandsCouldNotBeRunOnDefaultQueue($collection); + $this->exclusiveCommandProcessorMustBeSingleOnGivenQueue($collection); + $this->customQueueNamesUnique($collection); + $this->defaultQueueMustBePrefixed($collection); + } + } + + private function exclusiveCommandsCouldNotBeRunOnDefaultQueue(RouteCollection $collection): void + { + foreach ($collection->all() as $route) { + if ($route->isCommand() && $route->isProcessorExclusive() && false == $route->getQueue()) { + throw new \LogicException(sprintf('The command "%s" processor "%s" is exclusive but queue is not specified. Exclusive processors could not be run on a default queue.', $route->getSource(), $route->getProcessor())); + } + } + } + + private function exclusiveCommandProcessorMustBeSingleOnGivenQueue(RouteCollection $collection): void + { + $prefixedQueues = []; + $queues = []; + foreach ($collection->all() as $route) { + if (false == $route->isCommand()) { + continue; + } + if (false == $route->isProcessorExclusive()) { + continue; + } + + if ($route->isPrefixQueue()) { + if (array_key_exists($route->getQueue(), $prefixedQueues)) { + throw new \LogicException(sprintf('The command "%s" processor "%s" is exclusive. The queue "%s" already has another exclusive command processor "%s" bound to it.', $route->getSource(), $route->getProcessor(), $route->getQueue(), $prefixedQueues[$route->getQueue()])); + } + + $prefixedQueues[$route->getQueue()] = $route->getProcessor(); + } else { + if (array_key_exists($route->getQueue(), $queues)) { + throw new \LogicException(sprintf('The command "%s" processor "%s" is exclusive. The queue "%s" already has another exclusive command processor "%s" bound to it.', $route->getSource(), $route->getProcessor(), $route->getQueue(), $queues[$route->getQueue()])); + } + + $queues[$route->getQueue()] = $route->getProcessor(); + } + } + } + + private function defaultQueueMustBePrefixed(RouteCollection $collection): void + { + foreach ($collection->all() as $route) { + if (false == $route->getQueue() && false == $route->isPrefixQueue()) { + throw new \LogicException('The default queue must be prefixed.'); + } + } + } + + private function customQueueNamesUnique(RouteCollection $collection): void + { + $prefixedQueues = []; + $notPrefixedQueues = []; + + foreach ($collection->all() as $route) { + // default queue + $queueName = $route->getQueue(); + if (false == $queueName) { + return; + } + + $route->isPrefixQueue() ? + $prefixedQueues[$queueName] = $queueName : + $notPrefixedQueues[$queueName] = $queueName + ; + } + + foreach ($notPrefixedQueues as $queueName) { + if (array_key_exists($queueName, $prefixedQueues)) { + throw new \LogicException(sprintf('There are prefixed and not prefixed queue with the same name "%s". This is not allowed.', $queueName)); + } + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildClientExtensionsPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildClientExtensionsPass.php new file mode 100644 index 000000000..92124f243 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildClientExtensionsPass.php @@ -0,0 +1,63 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + $defaultName = $container->getParameter('enqueue.default_client'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + + $extensionsId = $diUtils->format('client_extensions'); + if (false == $container->hasDefinition($extensionsId)) { + throw new \LogicException(sprintf('Service "%s" not found', $extensionsId)); + } + + $tags = array_merge( + $container->findTaggedServiceIds('enqueue.client_extension'), + $container->findTaggedServiceIds('enqueue.client.extension') // TODO BC + ); + + $groupByPriority = []; + foreach ($tags as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + $client = $tagAttribute['client'] ?? $defaultName; + + if ($client !== $name && 'all' !== $client) { + continue; + } + + $priority = (int) ($tagAttribute['priority'] ?? 0); + + $groupByPriority[$priority][] = new Reference($serviceId); + } + } + + krsort($groupByPriority, \SORT_NUMERIC); + + $flatExtensions = []; + foreach ($groupByPriority as $extension) { + $flatExtensions = array_merge($flatExtensions, $extension); + } + + $extensionsService = $container->getDefinition($extensionsId); + $extensionsService->replaceArgument(0, array_merge( + $extensionsService->getArgument(0), + $flatExtensions + )); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPass.php new file mode 100644 index 000000000..4adc09e9d --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPass.php @@ -0,0 +1,135 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + $defaultName = $container->getParameter('enqueue.default_client'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + $routeCollectionId = $diUtils->format('route_collection'); + if (false == $container->hasDefinition($routeCollectionId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routeCollectionId)); + } + + $tag = 'enqueue.command_subscriber'; + $routeCollection = new RouteCollection([]); + foreach ($container->findTaggedServiceIds($tag) as $serviceId => $tagAttributes) { + $processorDefinition = $container->getDefinition($serviceId); + if ($processorDefinition->getFactory()) { + throw new \LogicException('The command subscriber tag could not be applied to a service created by factory.'); + } + + $processorClass = $processorDefinition->getClass() ?? $serviceId; + if (false == class_exists($processorClass)) { + throw new \LogicException(sprintf('The processor class "%s" could not be found.', $processorClass)); + } + + if (false == is_subclass_of($processorClass, CommandSubscriberInterface::class)) { + throw new \LogicException(sprintf('The processor must implement "%s" interface to be used with the tag "%s"', CommandSubscriberInterface::class, $tag)); + } + + foreach ($tagAttributes as $tagAttribute) { + $client = $tagAttribute['client'] ?? $defaultName; + + if ($client !== $name && 'all' !== $client) { + continue; + } + + /** @var CommandSubscriberInterface $processorClass */ + $commands = $processorClass::getSubscribedCommand(); + + if (empty($commands)) { + throw new \LogicException('Command subscriber must return something.'); + } + + if (is_string($commands)) { + $commands = [$commands]; + } + + if (!is_array($commands)) { + throw new \LogicException('Command subscriber configuration is invalid. Should be an array or string.'); + } + + // 0.8 command subscriber + if (isset($commands['processorName'])) { + @trigger_error('The command subscriber 0.8 syntax is deprecated since Enqueue 0.9.', \E_USER_DEPRECATED); + + $source = $commands['processorName']; + $processor = $params['processorName'] ?? $serviceId; + + $options = $commands; + unset( + $options['processorName'], + $options['queueName'], + $options['queueNameHardcoded'], + $options['exclusive'], + $options['topic'], + $options['source'], + $options['source_type'], + $options['processor'], + $options['options'] + ); + + $options['processor_service_id'] = $serviceId; + + if (isset($commands['queueName'])) { + $options['queue'] = $commands['queueName']; + } + + if (isset($commands['queueNameHardcoded']) && $commands['queueNameHardcoded']) { + $options['prefix_queue'] = false; + } + + $routeCollection->add(new Route($source, Route::COMMAND, $processor, $options)); + + continue; + } + + if (isset($commands['command'])) { + $commands = [$commands]; + } + + foreach ($commands as $key => $params) { + if (is_string($params)) { + $routeCollection->add(new Route($params, Route::COMMAND, $serviceId, ['processor_service_id' => $serviceId])); + } elseif (is_array($params)) { + $source = $params['command'] ?? null; + $processor = $params['processor'] ?? $serviceId; + unset($params['command'], $params['source'], $params['source_type'], $params['processor'], $params['options']); + $options = $params; + $options['processor_service_id'] = $serviceId; + + $routeCollection->add(new Route($source, Route::COMMAND, $processor, $options)); + } else { + throw new \LogicException(sprintf('Command subscriber configuration is invalid for "%s::getSubscribedCommand()". "%s"', $processorClass, json_encode($processorClass::getSubscribedCommand()))); + } + } + } + } + + $rawRoutes = $routeCollection->toArray(); + + $routeCollectionService = $container->getDefinition($routeCollectionId); + $routeCollectionService->replaceArgument(0, array_merge( + $routeCollectionService->getArgument(0), + $rawRoutes + )); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPass.php new file mode 100644 index 000000000..274847c90 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPass.php @@ -0,0 +1,63 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + $defaultName = $container->getParameter('enqueue.default_client'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + + $extensionsId = $diUtils->format('consumption_extensions'); + if (false == $container->hasDefinition($extensionsId)) { + throw new \LogicException(sprintf('Service "%s" not found', $extensionsId)); + } + + $tags = array_merge( + $container->findTaggedServiceIds('enqueue.consumption_extension'), + $container->findTaggedServiceIds('enqueue.consumption.extension') // TODO BC + ); + + $groupByPriority = []; + foreach ($tags as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + $client = $tagAttribute['client'] ?? $defaultName; + + if ($client !== $name && 'all' !== $client) { + continue; + } + + $priority = (int) ($tagAttribute['priority'] ?? 0); + + $groupByPriority[$priority][] = new Reference($serviceId); + } + } + + krsort($groupByPriority, \SORT_NUMERIC); + + $flatExtensions = []; + foreach ($groupByPriority as $extension) { + $flatExtensions = array_merge($flatExtensions, $extension); + } + + $extensionsService = $container->getDefinition($extensionsId); + $extensionsService->replaceArgument(0, array_merge( + $extensionsService->getArgument(0), + $flatExtensions + )); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRegistryPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRegistryPass.php new file mode 100644 index 000000000..3759dd209 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRegistryPass.php @@ -0,0 +1,57 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + + $processorRegistryId = $diUtils->format('processor_registry'); + if (false == $container->hasDefinition($processorRegistryId)) { + throw new \LogicException(sprintf('Service "%s" not found', $processorRegistryId)); + } + + $routeCollectionId = $diUtils->format('route_collection'); + if (false == $container->hasDefinition($routeCollectionId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routeCollectionId)); + } + + $routerProcessorId = $diUtils->format('router_processor'); + if (false == $container->hasDefinition($routerProcessorId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routerProcessorId)); + } + + $routeCollection = RouteCollection::fromArray($container->getDefinition($routeCollectionId)->getArgument(0)); + + $map = []; + foreach ($routeCollection->all() as $route) { + if (false == $processorServiceId = $route->getOption('processor_service_id')) { + throw new \LogicException('The route option "processor_service_id" is required'); + } + + $map[$route->getProcessor()] = new Reference($processorServiceId); + } + + $map[$diUtils->parameter('router_processor')] = new Reference($routerProcessorId); + + $registry = $container->getDefinition($processorRegistryId); + $registry->setArgument(0, ServiceLocatorTagPass::register($container, $map, $processorRegistryId)); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRoutesPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRoutesPass.php new file mode 100644 index 000000000..e88cb1f83 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildProcessorRoutesPass.php @@ -0,0 +1,77 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + $defaultName = $container->getParameter('enqueue.default_client'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + $routeCollectionId = $diUtils->format('route_collection'); + if (false == $container->hasDefinition($routeCollectionId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routeCollectionId)); + } + + $tag = 'enqueue.processor'; + $routeCollection = new RouteCollection([]); + foreach ($container->findTaggedServiceIds($tag) as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + $client = $tagAttribute['client'] ?? $defaultName; + + if ($client !== $name && 'all' !== $client) { + continue; + } + + $topic = $tagAttribute['topic'] ?? null; + $command = $tagAttribute['command'] ?? null; + + if (false == $topic && false == $command) { + throw new \LogicException(sprintf('Either "topic" or "command" tag attribute must be set on service "%s". None is set.', $serviceId)); + } + if ($topic && $command) { + throw new \LogicException(sprintf('Either "topic" or "command" tag attribute must be set on service "%s". Both are set.', $serviceId)); + } + + $source = $command ?: $topic; + $sourceType = $command ? Route::COMMAND : Route::TOPIC; + $processor = $tagAttribute['processor'] ?? $serviceId; + + unset( + $tagAttribute['topic'], + $tagAttribute['command'], + $tagAttribute['source'], + $tagAttribute['source_type'], + $tagAttribute['processor'], + $tagAttribute['options'] + ); + $options = $tagAttribute; + $options['processor_service_id'] = $serviceId; + + $routeCollection->add(new Route($source, $sourceType, $processor, $options)); + } + } + + $rawRoutes = $routeCollection->toArray(); + + $routeCollectionService = $container->getDefinition($routeCollectionId); + $routeCollectionService->replaceArgument(0, array_merge( + $routeCollectionService->getArgument(0), + $rawRoutes + )); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPass.php b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPass.php new file mode 100644 index 000000000..ef01e6fcf --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPass.php @@ -0,0 +1,127 @@ +hasParameter('enqueue.clients')) { + throw new \LogicException('The "enqueue.clients" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.clients'); + $defaultName = $container->getParameter('enqueue.default_client'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(ClientFactory::MODULE, $name); + $routeCollectionId = $diUtils->format('route_collection'); + if (false == $container->hasDefinition($routeCollectionId)) { + throw new \LogicException(sprintf('Service "%s" not found', $routeCollectionId)); + } + + $tag = 'enqueue.topic_subscriber'; + $routeCollection = new RouteCollection([]); + foreach ($container->findTaggedServiceIds($tag) as $serviceId => $tagAttributes) { + $processorDefinition = $container->getDefinition($serviceId); + if ($processorDefinition->getFactory()) { + throw new \LogicException('The topic subscriber tag could not be applied to a service created by factory.'); + } + + $processorClass = $processorDefinition->getClass() ?? $serviceId; + if (false == class_exists($processorClass)) { + throw new \LogicException(sprintf('The processor class "%s" could not be found.', $processorClass)); + } + + if (false == is_subclass_of($processorClass, TopicSubscriberInterface::class)) { + throw new \LogicException(sprintf('The processor must implement "%s" interface to be used with the tag "%s"', TopicSubscriberInterface::class, $tag)); + } + + foreach ($tagAttributes as $tagAttribute) { + $client = $tagAttribute['client'] ?? $defaultName; + + if ($client !== $name && 'all' !== $client) { + continue; + } + + /** @var TopicSubscriberInterface $processorClass */ + $topics = $processorClass::getSubscribedTopics(); + + if (empty($topics)) { + throw new \LogicException('Topic subscriber must return something.'); + } + + if (is_string($topics)) { + $topics = [$topics]; + } + + if (!is_array($topics)) { + throw new \LogicException('Topic subscriber configuration is invalid. Should be an array or string.'); + } + + foreach ($topics as $key => $params) { + if (is_string($params)) { + $routeCollection->add(new Route($params, Route::TOPIC, $serviceId, ['processor_service_id' => $serviceId])); + + // 0.8 topic subscriber + } elseif (is_array($params) && is_string($key)) { + @trigger_error('The topic subscriber 0.8 syntax is deprecated since Enqueue 0.9.', \E_USER_DEPRECATED); + + $source = $key; + $processor = $params['processorName'] ?? $serviceId; + + $options = $params; + unset( + $options['processorName'], + $options['queueName'], + $options['queueNameHardcoded'], + $options['topic'], + $options['source'], + $options['source_type'], + $options['processor'], + $options['options'] + ); + + $options['processor_service_id'] = $serviceId; + + if (isset($params['queueName'])) { + $options['queue'] = $params['queueName']; + } + + if (isset($params['queueNameHardcoded']) && $params['queueNameHardcoded']) { + $options['prefix_queue'] = false; + } + + $routeCollection->add(new Route($source, Route::TOPIC, $processor, $options)); + } elseif (is_array($params)) { + $source = $params['topic'] ?? null; + $processor = $params['processor'] ?? $serviceId; + unset($params['topic'], $params['source'], $params['source_type'], $params['processor'], $params['options']); + $options = $params; + $options['processor_service_id'] = $serviceId; + + $routeCollection->add(new Route($source, Route::TOPIC, $processor, $options)); + } else { + throw new \LogicException(sprintf('Topic subscriber configuration is invalid for "%s::getSubscribedTopics()". Got "%s"', $processorClass, json_encode($processorClass::getSubscribedTopics()))); + } + } + } + } + + $rawRoutes = $routeCollection->toArray(); + + $routeCollectionService = $container->getDefinition($routeCollectionId); + $routeCollectionService->replaceArgument(0, array_merge( + $routeCollectionService->getArgument(0), + $rawRoutes + )); + } + } +} diff --git a/pkg/enqueue/Symfony/Client/DependencyInjection/ClientFactory.php b/pkg/enqueue/Symfony/Client/DependencyInjection/ClientFactory.php new file mode 100644 index 000000000..be020dcff --- /dev/null +++ b/pkg/enqueue/Symfony/Client/DependencyInjection/ClientFactory.php @@ -0,0 +1,254 @@ +default = $default; + $this->diUtils = DiUtils::create(self::MODULE, $name); + } + + public static function getConfiguration(bool $debug, string $name = 'client'): NodeDefinition + { + $builder = new ArrayNodeDefinition($name); + + $builder->children() + ->booleanNode('traceable_producer')->defaultValue($debug)->end() + ->scalarNode('prefix')->defaultValue('enqueue')->end() + ->scalarNode('separator')->defaultValue('.')->end() + ->scalarNode('app_name')->defaultValue('app')->end() + ->scalarNode('router_topic')->defaultValue('default')->cannotBeEmpty()->end() + ->scalarNode('router_queue')->defaultValue('default')->cannotBeEmpty()->end() + ->scalarNode('router_processor')->defaultNull()->end() + ->integerNode('redelivered_delay_time')->min(0)->defaultValue(0)->end() + ->scalarNode('default_queue')->defaultValue('default')->cannotBeEmpty()->end() + ->arrayNode('driver_options')->addDefaultsIfNotSet()->info('The array contains driver specific options')->ignoreExtraKeys(false)->end() + ->end() + ; + + return $builder; + } + + public function build(ContainerBuilder $container, array $config): void + { + $container->register($this->diUtils->format('context'), Context::class) + ->setFactory([$this->diUtils->reference('driver'), 'getContext']) + ; + + $container->register($this->diUtils->format('driver_factory'), DriverFactory::class); + + $routerProcessor = empty($config['router_processor']) + ? $this->diUtils->format('router_processor') + : $config['router_processor'] + ; + + $container->register($this->diUtils->format('config'), Config::class) + ->setArguments([ + $config['prefix'], + $config['separator'], + $config['app_name'], + $config['router_topic'], + $config['router_queue'], + $config['default_queue'], + $routerProcessor, + $config['transport'], + $config['driver_options'] ?? [], + ]); + + $container->setParameter($this->diUtils->format('router_processor'), $routerProcessor); + $container->setParameter($this->diUtils->format('router_queue_name'), $config['router_queue']); + $container->setParameter($this->diUtils->format('default_queue_name'), $config['default_queue']); + + $container->register($this->diUtils->format('route_collection'), RouteCollection::class) + ->addArgument([]) + ->setFactory([RouteCollection::class, 'fromArray']) + ; + + $container->register($this->diUtils->format('producer'), Producer::class) + // @deprecated + ->setPublic(true) + ->addArgument($this->diUtils->reference('driver')) + ->addArgument($this->diUtils->reference('rpc_factory')) + ->addArgument($this->diUtils->reference('client_extensions')) + ; + + $lazyProducer = $container->register($this->diUtils->format('lazy_producer'), LazyProducer::class); + $lazyProducer->addArgument(ServiceLocatorTagPass::register($container, [ + $this->diUtils->format('producer') => new Reference($this->diUtils->format('producer')), + ])); + $lazyProducer->addArgument($this->diUtils->format('producer')); + + $container->register($this->diUtils->format('spool_producer'), SpoolProducer::class) + ->addArgument($this->diUtils->reference('lazy_producer')) + ; + + $container->register($this->diUtils->format('client_extensions'), ChainExtension::class) + ->addArgument([]) + ; + + $container->register($this->diUtils->format('rpc_factory'), RpcFactory::class) + ->addArgument($this->diUtils->reference('context')) + ; + + $container->register($this->diUtils->format('router_processor'), RouterProcessor::class) + ->addArgument($this->diUtils->reference('driver')) + ; + + $container->register($this->diUtils->format('processor_registry'), ContainerProcessorRegistry::class); + + $container->register($this->diUtils->format('delegate_processor'), DelegateProcessor::class) + ->addArgument($this->diUtils->reference('processor_registry')) + ; + + $container->register($this->diUtils->format('set_router_properties_extension'), SetRouterPropertiesExtension::class) + ->addArgument($this->diUtils->reference('driver')) + ->addTag('enqueue.consumption_extension', ['priority' => 100, 'client' => $this->diUtils->getConfigName()]) + ; + + $container->register($this->diUtils->format('queue_consumer'), QueueConsumer::class) + ->addArgument($this->diUtils->reference('context')) + ->addArgument($this->diUtils->reference('consumption_extensions')) + ->addArgument([]) + ->addArgument(new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->addArgument($config['consumption']['receive_timeout']) + ; + + $container->register($this->diUtils->format('consumption_extensions'), ConsumptionChainExtension::class) + ->addArgument([]) + ; + + $container->register($this->diUtils->format('flush_spool_producer_extension'), FlushSpoolProducerExtension::class) + ->addArgument($this->diUtils->reference('spool_producer')) + ->addTag('enqueue.consumption.extension', ['priority' => -100, 'client' => $this->diUtils->getConfigName()]) + ; + + $container->register($this->diUtils->format('exclusive_command_extension'), ExclusiveCommandExtension::class) + ->addArgument($this->diUtils->reference('driver')) + ->addTag('enqueue.consumption.extension', ['priority' => 100, 'client' => $this->diUtils->getConfigName()]) + ; + + if ($config['traceable_producer']) { + $container->register($this->diUtils->format('traceable_producer'), TraceableProducer::class) + ->setDecoratedService($this->diUtils->format('producer')) + ->addArgument($this->diUtils->reference('traceable_producer.inner')) + ; + } + + if ($config['redelivered_delay_time']) { + $container->register($this->diUtils->format('delay_redelivered_message_extension'), DelayRedeliveredMessageExtension::class) + ->addArgument($this->diUtils->reference('driver')) + ->addArgument($config['redelivered_delay_time']) + ->addTag('enqueue.consumption_extension', ['priority' => 10, 'client' => $this->diUtils->getConfigName()]) + ; + + $container->getDefinition($this->diUtils->format('delay_redelivered_message_extension')) + ->replaceArgument(1, $config['redelivered_delay_time']) + ; + } + + $locatorId = 'enqueue.locator'; + if ($container->hasDefinition($locatorId)) { + $locator = $container->getDefinition($locatorId); + $locator->replaceArgument(0, array_replace($locator->getArgument(0), [ + $this->diUtils->format('queue_consumer') => $this->diUtils->reference('queue_consumer'), + $this->diUtils->format('driver') => $this->diUtils->reference('driver'), + $this->diUtils->format('delegate_processor') => $this->diUtils->reference('delegate_processor'), + $this->diUtils->format('producer') => $this->diUtils->reference('lazy_producer'), + ])); + } + + if ($this->default) { + $container->setAlias(ProducerInterface::class, $this->diUtils->format('lazy_producer')); + $container->setAlias(SpoolProducer::class, $this->diUtils->format('spool_producer')); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('producer'), $this->diUtils->format('producer')); + $container->setAlias($this->diUtils->formatDefault('spool_producer'), $this->diUtils->format('spool_producer')); + } + } + } + + public function createDriver(ContainerBuilder $container, array $config): string + { + $factoryId = DiUtils::create(TransportFactory::MODULE, $this->diUtils->getConfigName())->format('connection_factory'); + $driverId = $this->diUtils->format('driver'); + $driverFactoryId = $this->diUtils->format('driver_factory'); + + $container->register($driverId, DriverInterface::class) + ->setFactory([new Reference($driverFactoryId), 'create']) + ->addArgument(new Reference($factoryId)) + ->addArgument($this->diUtils->reference('config')) + ->addArgument($this->diUtils->reference('route_collection')) + ; + + if ($this->default) { + $container->setAlias(DriverInterface::class, $driverId); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('driver'), $driverId); + } + } + + return $driverId; + } + + public function createFlushSpoolProducerListener(ContainerBuilder $container): void + { + $container->register($this->diUtils->format('flush_spool_producer_listener'), FlushSpoolProducerListener::class) + ->addArgument($this->diUtils->reference('spool_producer')) + ->addTag('kernel.event_subscriber') + ; + } +} diff --git a/pkg/enqueue/Symfony/Client/FlushSpoolProducerListener.php b/pkg/enqueue/Symfony/Client/FlushSpoolProducerListener.php new file mode 100644 index 000000000..00543f6de --- /dev/null +++ b/pkg/enqueue/Symfony/Client/FlushSpoolProducerListener.php @@ -0,0 +1,41 @@ +producer = $producer; + } + + public function flushMessages() + { + $this->producer->flush(); + } + + public static function getSubscribedEvents(): array + { + $events = []; + + if (class_exists(KernelEvents::class)) { + $events[KernelEvents::TERMINATE] = 'flushMessages'; + } + + if (class_exists(ConsoleEvents::class)) { + $events[ConsoleEvents::TERMINATE] = 'flushMessages'; + } + + return $events; + } +} diff --git a/pkg/enqueue/Symfony/Client/LazyProducer.php b/pkg/enqueue/Symfony/Client/LazyProducer.php new file mode 100644 index 000000000..8dd3aadba --- /dev/null +++ b/pkg/enqueue/Symfony/Client/LazyProducer.php @@ -0,0 +1,37 @@ +container = $container; + $this->producerId = $producerId; + } + + public function sendEvent(string $topic, $message): void + { + $this->getRealProducer()->sendEvent($topic, $message); + } + + public function sendCommand(string $command, $message, bool $needReply = false): ?Promise + { + return $this->getRealProducer()->sendCommand($command, $message, $needReply); + } + + private function getRealProducer(): ProducerInterface + { + return $this->container->get($this->producerId); + } +} diff --git a/pkg/enqueue/Symfony/Client/Meta/QueuesCommand.php b/pkg/enqueue/Symfony/Client/Meta/QueuesCommand.php deleted file mode 100644 index 6f37679dc..000000000 --- a/pkg/enqueue/Symfony/Client/Meta/QueuesCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -destinationRegistry = $queueRegistry; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this - ->setName('enqueue:queues') - ->setAliases([ - 'enq:m:q', - 'debug:enqueue:queues', - ]) - ->setDescription('A command shows all available queues and some information about them.') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $table = new Table($output); - $table->setHeaders(['Client Name', 'Transport Name', 'processors']); - - $count = 0; - $firstRow = true; - foreach ($this->destinationRegistry->getQueuesMeta() as $destination) { - if (false == $firstRow) { - $table->addRow(new TableSeparator()); - } - - $table->addRow([ - $destination->getClientName(), - $destination->getTransportName(), - implode(PHP_EOL, $destination->getProcessors()), - ]); - - ++$count; - $firstRow = false; - } - - $output->writeln(sprintf('Found %s destinations', $count)); - $output->writeln(''); - $table->render(); - } -} diff --git a/pkg/enqueue/Symfony/Client/Meta/TopicsCommand.php b/pkg/enqueue/Symfony/Client/Meta/TopicsCommand.php deleted file mode 100644 index 8314fb99a..000000000 --- a/pkg/enqueue/Symfony/Client/Meta/TopicsCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -topicRegistry = $topicRegistry; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this - ->setName('enqueue:topics') - ->setAliases([ - 'enq:m:t', - 'debug:enqueue:topics', - ]) - ->setDescription('A command shows all available topics and some information about them.') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $table = new Table($output); - $table->setHeaders(['Topic', 'Description', 'processors']); - - $count = 0; - $firstRow = true; - foreach ($this->topicRegistry->getTopicsMeta() as $topic) { - if (false == $firstRow) { - $table->addRow(new TableSeparator()); - } - - $table->addRow([$topic->getName(), $topic->getDescription(), implode(PHP_EOL, $topic->getProcessors())]); - - ++$count; - $firstRow = false; - } - - $output->writeln(sprintf('Found %s topics', $count)); - $output->writeln(''); - $table->render(); - } -} diff --git a/pkg/enqueue/Symfony/Client/ProduceCommand.php b/pkg/enqueue/Symfony/Client/ProduceCommand.php new file mode 100644 index 000000000..953a76687 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/ProduceCommand.php @@ -0,0 +1,92 @@ +container = $container; + $this->defaultClient = $defaultClient; + $this->producerIdPattern = $producerIdPattern; + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDescription('Sends an event to the topic') + ->addArgument('message', InputArgument::REQUIRED, 'A message') + ->addOption('header', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The message headers') + ->addOption('client', 'c', InputOption::VALUE_OPTIONAL, 'The client to consume messages from.', $this->defaultClient) + ->addOption('topic', null, InputOption::VALUE_OPTIONAL, 'The topic to send a message to') + ->addOption('command', null, InputOption::VALUE_OPTIONAL, 'The command to send a message to') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $topic = $input->getOption('topic'); + $command = $input->getOption('command'); + $message = $input->getArgument('message'); + $headers = (array) $input->getOption('header'); + $client = $input->getOption('client'); + + if ($topic && $command) { + throw new \LogicException('Either topic or command option should be set, both are set.'); + } + + try { + $producer = $this->getProducer($client); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Client "%s" is not supported.', $client), previous: $e); + } + + if ($topic) { + $producer->sendEvent($topic, new Message($message, [], $headers)); + + $output->writeln('An event is sent'); + } elseif ($command) { + $producer->sendCommand($command, $message); + + $output->writeln('A command is sent'); + } else { + throw new \LogicException('Either topic or command option should be set, none is set.'); + } + + return 0; + } + + private function getProducer(string $client): ProducerInterface + { + return $this->container->get(sprintf($this->producerIdPattern, $client)); + } +} diff --git a/pkg/enqueue/Symfony/Client/ProduceMessageCommand.php b/pkg/enqueue/Symfony/Client/ProduceMessageCommand.php deleted file mode 100644 index 15f0a7874..000000000 --- a/pkg/enqueue/Symfony/Client/ProduceMessageCommand.php +++ /dev/null @@ -1,54 +0,0 @@ -producer = $producer; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this - ->setName('enqueue:produce') - ->setAliases(['enq:p']) - ->setDescription('A command to send a message to topic') - ->addArgument('topic', InputArgument::REQUIRED, 'A topic to send message to') - ->addArgument('message', InputArgument::REQUIRED, 'A message to send') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $this->producer->send( - $input->getArgument('topic'), - $input->getArgument('message') - ); - - $output->writeln('Message is sent'); - } -} diff --git a/pkg/enqueue/Symfony/Client/RoutesCommand.php b/pkg/enqueue/Symfony/Client/RoutesCommand.php new file mode 100644 index 000000000..04b657ef7 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/RoutesCommand.php @@ -0,0 +1,162 @@ +container = $container; + $this->defaultClient = $defaultClient; + $this->driverIdPatter = $driverIdPatter; + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setAliases(['debug:enqueue:routes']) + ->setDescription('A command lists all registered routes.') + ->addOption('show-route-options', null, InputOption::VALUE_NONE, 'Adds ability to hide options.') + ->addOption('client', 'c', InputOption::VALUE_OPTIONAL, 'The client to consume messages from.', $this->defaultClient) + ; + + $this->driver = null; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $this->driver = $this->getDriver($input->getOption('client')); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Client "%s" is not supported.', $input->getOption('client')), previous: $e); + } + + $routes = $this->driver->getRouteCollection()->all(); + $output->writeln(sprintf('Found %s routes', count($routes))); + $output->writeln(''); + + if ($routes) { + $table = new Table($output); + $table->setHeaders(['Type', 'Source', 'Queue', 'Processor', 'Options']); + + $firstRow = true; + foreach ($routes as $route) { + if (false == $firstRow) { + $table->addRow(new TableSeparator()); + + $firstRow = false; + } + + if ($route->isCommand()) { + continue; + } + + $table->addRow([ + $this->formatSourceType($route), + $route->getSource(), + $this->formatQueue($route), + $this->formatProcessor($route), + $input->getOption('show-route-options') ? $this->formatOptions($route) : '(hidden)', + ]); + } + + foreach ($routes as $route) { + if ($route->isTopic()) { + continue; + } + + $table->addRow([ + $this->formatSourceType($route), + $route->getSource(), + $this->formatQueue($route), + $this->formatProcessor($route), + $input->getOption('show-route-options') ? $this->formatOptions($route) : '(hidden)', + ]); + } + + $table->render(); + } + + return 0; + } + + private function formatSourceType(Route $route): string + { + if ($route->isCommand()) { + return 'command'; + } + + if ($route->isTopic()) { + return 'topic'; + } + + return 'unknown'; + } + + private function formatProcessor(Route $route): string + { + if ($route->isProcessorExternal()) { + return 'n\a (external)'; + } + + $processor = $route->getProcessor(); + + return $route->isProcessorExclusive() ? $processor.' (exclusive)' : $processor; + } + + private function formatQueue(Route $route): string + { + $queue = $route->getQueue() ?: $this->driver->getConfig()->getDefaultQueue(); + + return $route->isPrefixQueue() ? $queue.' (prefixed)' : $queue.' (as is)'; + } + + private function formatOptions(Route $route): string + { + return var_export($route->getOptions(), true); + } + + private function getDriver(string $client): DriverInterface + { + return $this->container->get(sprintf($this->driverIdPatter, $client)); + } +} + +function enqueue() +{ +} diff --git a/pkg/enqueue/Symfony/Client/SetupBrokerCommand.php b/pkg/enqueue/Symfony/Client/SetupBrokerCommand.php index c825bd2c4..92d5ad022 100644 --- a/pkg/enqueue/Symfony/Client/SetupBrokerCommand.php +++ b/pkg/enqueue/Symfony/Client/SetupBrokerCommand.php @@ -3,47 +3,68 @@ namespace Enqueue\Symfony\Client; use Enqueue\Client\DriverInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Logger\ConsoleLogger; use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand('enqueue:setup-broker')] class SetupBrokerCommand extends Command { /** - * @var DriverInterface + * @var ContainerInterface */ - private $driver; + private $container; /** - * @param DriverInterface $driver + * @var string */ - public function __construct(DriverInterface $driver) + private $defaultClient; + + /** + * @var string + */ + private $driverIdPattern; + + public function __construct(ContainerInterface $container, string $defaultClient, string $driverIdPattern = 'enqueue.client.%s.driver') { - parent::__construct(null); + $this->container = $container; + $this->defaultClient = $defaultClient; + $this->driverIdPattern = $driverIdPattern; - $this->driver = $driver; + parent::__construct(); } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('enqueue:setup-broker') ->setAliases(['enq:sb']) - ->setDescription('Creates all required queues') + ->setDescription('Setup broker. Configure the broker, creates queues, topics and so on.') + ->addOption('client', 'c', InputOption::VALUE_OPTIONAL, 'The client to consume messages from.', $this->defaultClient) ; } - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Setup Broker'); + $client = $input->getOption('client'); + + try { + $this->getDriver($client)->setupBroker(new ConsoleLogger($output)); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Client "%s" is not supported.', $client), previous: $e); + } + + $output->writeln('Broker set up'); - $this->driver->setupBroker(new ConsoleLogger($output)); + return 0; + } + + private function getDriver(string $client): DriverInterface + { + return $this->container->get(sprintf($this->driverIdPattern, $client)); } } diff --git a/pkg/enqueue/Symfony/Client/SetupBrokerExtensionCommandTrait.php b/pkg/enqueue/Symfony/Client/SetupBrokerExtensionCommandTrait.php index 2888f5636..bcc4f7bb2 100644 --- a/pkg/enqueue/Symfony/Client/SetupBrokerExtensionCommandTrait.php +++ b/pkg/enqueue/Symfony/Client/SetupBrokerExtensionCommandTrait.php @@ -10,9 +10,6 @@ trait SetupBrokerExtensionCommandTrait { - /** - * {@inheritdoc} - */ protected function configureSetupBrokerExtension() { $this @@ -21,10 +18,7 @@ protected function configureSetupBrokerExtension() } /** - * @param InputInterface $input - * @param DriverInterface $driver - * - * @return ExtensionInterface + * @return ExtensionInterface|null */ protected function getSetupBrokerExtension(InputInterface $input, DriverInterface $driver) { diff --git a/pkg/enqueue/Symfony/Client/SimpleConsumeCommand.php b/pkg/enqueue/Symfony/Client/SimpleConsumeCommand.php new file mode 100644 index 000000000..fafc35d05 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/SimpleConsumeCommand.php @@ -0,0 +1,26 @@ + $queueConsumer, + 'driver' => $driver, + 'processor' => $processor, + ]), + 'default', + 'queue_consumer', + 'driver', + 'processor' + ); + } +} diff --git a/pkg/enqueue/Symfony/Client/SimpleProduceCommand.php b/pkg/enqueue/Symfony/Client/SimpleProduceCommand.php new file mode 100644 index 000000000..5d7f76533 --- /dev/null +++ b/pkg/enqueue/Symfony/Client/SimpleProduceCommand.php @@ -0,0 +1,18 @@ + $producer]), + 'default', + 'producer' + ); + } +} diff --git a/pkg/enqueue/Symfony/Client/SimpleRoutesCommand.php b/pkg/enqueue/Symfony/Client/SimpleRoutesCommand.php new file mode 100644 index 000000000..0023f14ae --- /dev/null +++ b/pkg/enqueue/Symfony/Client/SimpleRoutesCommand.php @@ -0,0 +1,18 @@ + $driver]), + 'default', + 'driver' + ); + } +} diff --git a/pkg/enqueue/Symfony/Client/SimpleSetupBrokerCommand.php b/pkg/enqueue/Symfony/Client/SimpleSetupBrokerCommand.php new file mode 100644 index 000000000..aae19f84b --- /dev/null +++ b/pkg/enqueue/Symfony/Client/SimpleSetupBrokerCommand.php @@ -0,0 +1,18 @@ + $driver]), + 'default', + 'driver' + ); + } +} diff --git a/pkg/enqueue/Symfony/Consumption/ChooseLoggerCommandTrait.php b/pkg/enqueue/Symfony/Consumption/ChooseLoggerCommandTrait.php new file mode 100644 index 000000000..c229c14b0 --- /dev/null +++ b/pkg/enqueue/Symfony/Consumption/ChooseLoggerCommandTrait.php @@ -0,0 +1,35 @@ +addOption('logger', null, InputOption::VALUE_OPTIONAL, 'A logger to be used. Could be "default", "null", "stdout".', 'default') + ; + } + + protected function getLoggerExtension(InputInterface $input, OutputInterface $output): ?LoggerExtension + { + $logger = $input->getOption('logger'); + switch ($logger) { + case 'null': + return new LoggerExtension(new NullLogger()); + case 'stdout': + return new LoggerExtension(new ConsoleLogger($output)); + case 'default': + return null; + default: + throw new \LogicException(sprintf('The logger "%s" is not supported', $logger)); + } + } +} diff --git a/pkg/enqueue/Symfony/Consumption/ConfigurableConsumeCommand.php b/pkg/enqueue/Symfony/Consumption/ConfigurableConsumeCommand.php new file mode 100644 index 000000000..34cb66d57 --- /dev/null +++ b/pkg/enqueue/Symfony/Consumption/ConfigurableConsumeCommand.php @@ -0,0 +1,122 @@ +container = $container; + $this->defaultTransport = $defaultTransport; + $this->queueConsumerIdPattern = $queueConsumerIdPattern; + $this->processorRegistryIdPattern = $processorRegistryIdPattern; + + parent::__construct(); + } + + protected function configure(): void + { + $this->configureLimitsExtensions(); + $this->configureQueueConsumerOptions(); + $this->configureLoggerExtension(); + + $this + ->setDescription('A worker that consumes message from a broker. '. + 'To use this broker you have to explicitly set a queue to consume from '. + 'and a message processor service') + ->addArgument('processor', InputArgument::REQUIRED, 'A message processor.') + ->addArgument('queues', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'A queue to consume from', []) + ->addOption('transport', 't', InputOption::VALUE_OPTIONAL, 'The transport to consume messages from.', $this->defaultTransport) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $transport = $input->getOption('transport'); + + try { + $consumer = $this->getQueueConsumer($transport); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Transport "%s" is not supported.', $transport), previous: $e); + } + + $this->setQueueConsumerOptions($consumer, $input); + + $processor = $this->getProcessorRegistry($transport)->get($input->getArgument('processor')); + + $queues = $input->getArgument('queues'); + if (empty($queues) && $processor instanceof QueueSubscriberInterface) { + $queues = $processor::getSubscribedQueues(); + } + + if (empty($queues)) { + throw new \LogicException(sprintf('The queue is not provided. The processor must implement "%s" interface and it must return not empty array of queues or a queue set using as a second argument.', QueueSubscriberInterface::class)); + } + + $extensions = $this->getLimitsExtensions($input, $output); + + if ($loggerExtension = $this->getLoggerExtension($input, $output)) { + array_unshift($extensions, $loggerExtension); + } + + foreach ($queues as $queue) { + $consumer->bind($queue, $processor); + } + + $consumer->consume(new ChainExtension($extensions)); + + return 0; + } + + private function getQueueConsumer(string $name): QueueConsumerInterface + { + return $this->container->get(sprintf($this->queueConsumerIdPattern, $name)); + } + + private function getProcessorRegistry(string $name): ProcessorRegistryInterface + { + return $this->container->get(sprintf($this->processorRegistryIdPattern, $name)); + } +} diff --git a/pkg/enqueue/Symfony/Consumption/ConsumeCommand.php b/pkg/enqueue/Symfony/Consumption/ConsumeCommand.php new file mode 100644 index 000000000..b69ae7269 --- /dev/null +++ b/pkg/enqueue/Symfony/Consumption/ConsumeCommand.php @@ -0,0 +1,91 @@ +container = $container; + $this->defaultTransport = $defaultTransport; + $this->queueConsumerIdPattern = $queueConsumerIdPattern; + + parent::__construct(); + } + + protected function configure(): void + { + $this->configureLimitsExtensions(); + $this->configureQueueConsumerOptions(); + $this->configureLoggerExtension(); + + $this + ->addOption('transport', 't', InputOption::VALUE_OPTIONAL, 'The transport to consume messages from.', $this->defaultTransport) + ->setDescription('A worker that consumes message from a broker. '. + 'To use this broker you have to configure queue consumer before adding to the command') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $transport = $input->getOption('transport'); + + try { + // QueueConsumer must be pre configured outside of the command! + $consumer = $this->getQueueConsumer($transport); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException(sprintf('Transport "%s" is not supported.', $transport), previous: $e); + } + + $this->setQueueConsumerOptions($consumer, $input); + + $extensions = $this->getLimitsExtensions($input, $output); + + if ($loggerExtension = $this->getLoggerExtension($input, $output)) { + array_unshift($extensions, $loggerExtension); + } + + $exitStatusExtension = new ExitStatusExtension(); + array_unshift($extensions, $exitStatusExtension); + + $consumer->consume(new ChainExtension($extensions)); + + return $exitStatusExtension->getExitStatus() ?? 0; + } + + private function getQueueConsumer(string $name): QueueConsumerInterface + { + return $this->container->get(sprintf($this->queueConsumerIdPattern, $name)); + } +} diff --git a/pkg/enqueue/Symfony/Consumption/ConsumeMessagesCommand.php b/pkg/enqueue/Symfony/Consumption/ConsumeMessagesCommand.php deleted file mode 100644 index 2f1ed8c7f..000000000 --- a/pkg/enqueue/Symfony/Consumption/ConsumeMessagesCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -consumer = $consumer; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this->configureLimitsExtensions(); - - $this - ->setName('enqueue:transport:consume') - ->setDescription('A worker that consumes message from a broker. '. - 'To use this broker you have to configure queue consumer before adding to the command') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $extensions = $this->getLimitsExtensions($input, $output); - array_unshift($extensions, new LoggerExtension(new ConsoleLogger($output))); - - $runtimeExtensions = new ChainExtension($extensions); - - try { - $this->consumer->consume($runtimeExtensions); - } finally { - $this->consumer->getPsrContext()->close(); - } - } -} diff --git a/pkg/enqueue/Symfony/Consumption/ContainerAwareConsumeMessagesCommand.php b/pkg/enqueue/Symfony/Consumption/ContainerAwareConsumeMessagesCommand.php deleted file mode 100644 index ad899cae6..000000000 --- a/pkg/enqueue/Symfony/Consumption/ContainerAwareConsumeMessagesCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -consumer = $consumer; - } - - /** - * {@inheritdoc} - */ - protected function configure() - { - $this->configureLimitsExtensions(); - - $this - ->setName('enqueue:transport:consume') - ->setDescription('A worker that consumes message from a broker. '. - 'To use this broker you have to explicitly set a queue to consume from '. - 'and a message processor service') - ->addArgument('queue', InputArgument::REQUIRED, 'Queues to consume from') - ->addArgument('processor-service', InputArgument::REQUIRED, 'A message processor service') - ; - } - - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) - { - $queueName = $input->getArgument('queue'); - - /** @var Processor $processor */ - $processor = $this->container->get($input->getArgument('processor-service')); - if (!$processor instanceof Processor) { - throw new \LogicException(sprintf( - 'Invalid message processor service given. It must be an instance of %s but %s', - Processor::class, - get_class($processor) - )); - } - - $extensions = $this->getLimitsExtensions($input, $output); - array_unshift($extensions, new LoggerExtension(new ConsoleLogger($output))); - - $runtimeExtensions = new ChainExtension($extensions); - - try { - $queue = $this->consumer->getPsrContext()->createQueue($queueName); - // @todo set additional queue options - - $this->consumer->bind($queue, $processor); - $this->consumer->consume($runtimeExtensions); - } finally { - $this->consumer->getPsrContext()->close(); - } - } -} diff --git a/pkg/enqueue/Symfony/Consumption/LimitsExtensionsCommandTrait.php b/pkg/enqueue/Symfony/Consumption/LimitsExtensionsCommandTrait.php index 34bc76d37..d8351acc5 100644 --- a/pkg/enqueue/Symfony/Consumption/LimitsExtensionsCommandTrait.php +++ b/pkg/enqueue/Symfony/Consumption/LimitsExtensionsCommandTrait.php @@ -5,6 +5,7 @@ use Enqueue\Consumption\Extension\LimitConsumedMessagesExtension; use Enqueue\Consumption\Extension\LimitConsumerMemoryExtension; use Enqueue\Consumption\Extension\LimitConsumptionTimeExtension; +use Enqueue\Consumption\Extension\NicenessExtension; use Enqueue\Consumption\ExtensionInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -12,21 +13,16 @@ trait LimitsExtensionsCommandTrait { - /** - * {@inheritdoc} - */ protected function configureLimitsExtensions() { $this ->addOption('message-limit', null, InputOption::VALUE_REQUIRED, 'Consume n messages and exit') ->addOption('time-limit', null, InputOption::VALUE_REQUIRED, 'Consume messages during this time') - ->addOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Consume messages until process reaches this memory limit in MB'); + ->addOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Consume messages until process reaches this memory limit in MB') + ->addOption('niceness', null, InputOption::VALUE_REQUIRED, 'Set process niceness'); } /** - * @param InputInterface $input - * @param OutputInterface $output - * * @throws \Exception * * @return ExtensionInterface[] @@ -58,6 +54,11 @@ protected function getLimitsExtensions(InputInterface $input, OutputInterface $o $extensions[] = new LimitConsumerMemoryExtension($memoryLimit); } + $niceness = $input->getOption('niceness'); + if (!empty($niceness) && is_numeric($niceness)) { + $extensions[] = new NicenessExtension((int) $niceness); + } + return $extensions; } } diff --git a/pkg/enqueue/Symfony/Consumption/QueueConsumerOptionsCommandTrait.php b/pkg/enqueue/Symfony/Consumption/QueueConsumerOptionsCommandTrait.php new file mode 100644 index 000000000..fd736f226 --- /dev/null +++ b/pkg/enqueue/Symfony/Consumption/QueueConsumerOptionsCommandTrait.php @@ -0,0 +1,24 @@ +addOption('receive-timeout', null, InputOption::VALUE_REQUIRED, 'The time in milliseconds queue consumer waits for a message.') + ; + } + + protected function setQueueConsumerOptions(QueueConsumerInterface $consumer, InputInterface $input) + { + if (null !== $receiveTimeout = $input->getOption('receive-timeout')) { + $consumer->setReceiveTimeout((int) $receiveTimeout); + } + } +} diff --git a/pkg/enqueue/Symfony/Consumption/SimpleConsumeCommand.php b/pkg/enqueue/Symfony/Consumption/SimpleConsumeCommand.php new file mode 100644 index 000000000..90d0e362e --- /dev/null +++ b/pkg/enqueue/Symfony/Consumption/SimpleConsumeCommand.php @@ -0,0 +1,18 @@ + $consumer]), + 'default', + 'queue_consumer' + ); + } +} diff --git a/pkg/enqueue/Symfony/ContainerProcessorRegistry.php b/pkg/enqueue/Symfony/ContainerProcessorRegistry.php new file mode 100644 index 000000000..b259d2379 --- /dev/null +++ b/pkg/enqueue/Symfony/ContainerProcessorRegistry.php @@ -0,0 +1,29 @@ +locator = $locator; + } + + public function get(string $processorName): Processor + { + if (false == $this->locator->has($processorName)) { + throw new \LogicException(sprintf('Service locator does not have a processor with name "%s".', $processorName)); + } + + return $this->locator->get($processorName); + } +} diff --git a/pkg/enqueue/Symfony/DefaultTransportFactory.php b/pkg/enqueue/Symfony/DefaultTransportFactory.php deleted file mode 100644 index d6fe81006..000000000 --- a/pkg/enqueue/Symfony/DefaultTransportFactory.php +++ /dev/null @@ -1,75 +0,0 @@ -name = $name; - } - - /** - * {@inheritdoc} - */ - public function addConfiguration(ArrayNodeDefinition $builder) - { - $builder - ->beforeNormalization() - ->ifString() - ->then(function ($v) { - return ['alias' => $v]; - }) - ->end() - ->children() - ->scalarNode('alias')->isRequired()->cannotBeEmpty()->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createContext(ContainerBuilder $container, array $config) - { - $contextId = sprintf('enqueue.transport.%s.context', $this->getName()); - $aliasId = sprintf('enqueue.transport.%s.context', $config['alias']); - - $container->setAlias($contextId, $aliasId); - $container->setAlias('enqueue.transport.context', $contextId); - - return $contextId; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $aliasId = sprintf('enqueue.client.%s.driver', $config['alias']); - - $container->setAlias($driverId, $aliasId); - $container->setAlias('enqueue.client.driver', $driverId); - - return $driverId; - } - - /** - * @return string - */ - public function getName() - { - return $this->name; - } -} diff --git a/pkg/enqueue/Symfony/DependencyInjection/BuildConsumptionExtensionsPass.php b/pkg/enqueue/Symfony/DependencyInjection/BuildConsumptionExtensionsPass.php new file mode 100644 index 000000000..99f274ec5 --- /dev/null +++ b/pkg/enqueue/Symfony/DependencyInjection/BuildConsumptionExtensionsPass.php @@ -0,0 +1,60 @@ +hasParameter('enqueue.transports')) { + throw new \LogicException('The "enqueue.transports" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.transports'); + $defaultName = $container->getParameter('enqueue.default_transport'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(TransportFactory::MODULE, $name); + + $extensionsId = $diUtils->format('consumption_extensions'); + if (false == $container->hasDefinition($extensionsId)) { + throw new \LogicException(sprintf('Service "%s" not found', $extensionsId)); + } + + $tags = $container->findTaggedServiceIds('enqueue.transport.consumption_extension'); + + $groupByPriority = []; + foreach ($tags as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + $transport = $tagAttribute['transport'] ?? $defaultName; + + if ($transport !== $name && 'all' !== $transport) { + continue; + } + + $priority = (int) ($tagAttribute['priority'] ?? 0); + + $groupByPriority[$priority][] = new Reference($serviceId); + } + } + + krsort($groupByPriority, \SORT_NUMERIC); + + $flatExtensions = []; + foreach ($groupByPriority as $extension) { + $flatExtensions = array_merge($flatExtensions, $extension); + } + + $extensionsService = $container->getDefinition($extensionsId); + $extensionsService->replaceArgument(0, array_merge( + $extensionsService->getArgument(0), + $flatExtensions + )); + } + } +} diff --git a/pkg/enqueue/Symfony/DependencyInjection/BuildProcessorRegistryPass.php b/pkg/enqueue/Symfony/DependencyInjection/BuildProcessorRegistryPass.php new file mode 100644 index 000000000..cc6e04270 --- /dev/null +++ b/pkg/enqueue/Symfony/DependencyInjection/BuildProcessorRegistryPass.php @@ -0,0 +1,50 @@ +hasParameter('enqueue.transports')) { + throw new \LogicException('The "enqueue.transports" parameter must be set.'); + } + + $names = $container->getParameter('enqueue.transports'); + $defaultName = $container->getParameter('enqueue.default_transport'); + + foreach ($names as $name) { + $diUtils = DiUtils::create(TransportFactory::MODULE, $name); + + $processorRegistryId = $diUtils->format('processor_registry'); + if (false == $container->hasDefinition($processorRegistryId)) { + throw new \LogicException(sprintf('Service "%s" not found', $processorRegistryId)); + } + + $tag = 'enqueue.transport.processor'; + $map = []; + foreach ($container->findTaggedServiceIds($tag) as $serviceId => $tagAttributes) { + foreach ($tagAttributes as $tagAttribute) { + $transport = $tagAttribute['transport'] ?? $defaultName; + + if ($transport !== $name && 'all' !== $transport) { + continue; + } + + $processor = $tagAttribute['processor'] ?? $serviceId; + + $map[$processor] = new Reference($serviceId); + } + } + + $registry = $container->getDefinition($processorRegistryId); + $registry->setArgument(0, ServiceLocatorTagPass::register($container, $map, $processorRegistryId)); + } + } +} diff --git a/pkg/enqueue/Symfony/DependencyInjection/TransportFactory.php b/pkg/enqueue/Symfony/DependencyInjection/TransportFactory.php new file mode 100644 index 000000000..944b1a30d --- /dev/null +++ b/pkg/enqueue/Symfony/DependencyInjection/TransportFactory.php @@ -0,0 +1,266 @@ +default = $default; + $this->diUtils = DiUtils::create(self::MODULE, $name); + } + + public static function getConfiguration(string $name = 'transport'): NodeDefinition + { + $knownSchemes = array_keys(Resources::getKnownSchemes()); + $availableSchemes = array_keys(Resources::getAvailableSchemes()); + + $builder = new ArrayNodeDefinition($name); + $builder + ->info('The transport option could accept a string DSN, an array with DSN key, or null. It accept extra options. To find out what option you can set, look at connection factory constructor docblock.') + ->beforeNormalization() + ->always(function ($v) { + if (empty($v)) { + return ['dsn' => 'null:']; + } + + if (is_array($v)) { + if (isset($v['factory_class']) && isset($v['factory_service'])) { + throw new \LogicException('Both options factory_class and factory_service are set. Please choose one.'); + } + + if (isset($v['connection_factory_class']) && (isset($v['factory_class']) || isset($v['factory_service']))) { + throw new \LogicException('The option connection_factory_class must not be used with factory_class or factory_service at the same time. Please choose one.'); + } + + return $v; + } + + if (is_string($v)) { + return ['dsn' => $v]; + } + + throw new \LogicException(sprintf('The value must be array, null or string. Got "%s"', gettype($v))); + }) + ->end() + ->isRequired() + ->ignoreExtraKeys(false) + ->children() + ->scalarNode('dsn') + ->cannotBeEmpty() + ->isRequired() + ->info(sprintf( + 'The MQ broker DSN. These schemes are supported: "%s", to use these "%s" you have to install a package.', + implode('", "', $knownSchemes), + implode('", "', $availableSchemes) + )) + ->end() + ->scalarNode('connection_factory_class') + ->info(sprintf('The connection factory class should implement "%s" interface', ConnectionFactory::class)) + ->end() + ->scalarNode('factory_service') + ->info(sprintf('The factory class should implement "%s" interface', ConnectionFactoryFactoryInterface::class)) + ->end() + ->scalarNode('factory_class') + ->info(sprintf('The factory service should be a class that implements "%s" interface', ConnectionFactoryFactoryInterface::class)) + ->end() + ->end() + ; + + return $builder; + } + + public static function getQueueConsumerConfiguration(string $name = 'consumption'): ArrayNodeDefinition + { + $builder = new ArrayNodeDefinition($name); + + $builder + ->addDefaultsIfNotSet()->children() + ->integerNode('receive_timeout') + ->min(0) + ->defaultValue(10000) + ->info('the time in milliseconds queue consumer waits for a message (10000 ms by default)') + ->end() + ; + + return $builder; + } + + public function buildConnectionFactory(ContainerBuilder $container, array $config): void + { + $factoryId = $this->diUtils->format('connection_factory'); + + $factoryFactoryId = $this->diUtils->format('connection_factory_factory'); + $container->register($factoryFactoryId, $config['factory_class'] ?? ConnectionFactoryFactory::class); + + $factoryFactoryService = new Reference( + $config['factory_service'] ?? $factoryFactoryId + ); + + unset($config['factory_service'], $config['factory_class']); + + $connectionFactoryClass = $config['connection_factory_class'] ?? null; + unset($config['connection_factory_class']); + + if (isset($connectionFactoryClass)) { + $container->register($factoryId, $connectionFactoryClass) + ->addArgument($config) + ; + } else { + $container->register($factoryId, ConnectionFactory::class) + ->setFactory([$factoryFactoryService, 'create']) + ->addArgument($config) + ; + } + + if ($this->default) { + $container->setAlias(ConnectionFactory::class, $factoryId); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('connection_factory'), $factoryId); + } + } + } + + public function buildContext(ContainerBuilder $container, array $config): void + { + $factoryId = $this->diUtils->format('connection_factory'); + $this->assertServiceExists($container, $factoryId); + + $contextId = $this->diUtils->format('context'); + + $container->register($contextId, Context::class) + ->setFactory([new Reference($factoryId), 'createContext']) + ; + + $this->addServiceToLocator($container, 'context'); + + if ($this->default) { + $container->setAlias(Context::class, $contextId); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('context'), $contextId); + } + } + } + + public function buildQueueConsumer(ContainerBuilder $container, array $config): void + { + $contextId = $this->diUtils->format('context'); + $this->assertServiceExists($container, $contextId); + + $container->setParameter($this->diUtils->format('receive_timeout'), $config['receive_timeout'] ?? 10000); + + $logExtensionId = $this->diUtils->format('log_extension'); + $container->register($logExtensionId, LogExtension::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => $this->diUtils->getConfigName(), 'priority' => -100]) + ; + + $container->register($this->diUtils->format('consumption_extensions'), ChainExtension::class) + ->addArgument([]) + ; + + $container->register($this->diUtils->format('queue_consumer'), QueueConsumer::class) + ->addArgument(new Reference($contextId)) + ->addArgument(new Reference($this->diUtils->format('consumption_extensions'))) + ->addArgument([]) + ->addArgument(new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->addArgument($this->diUtils->parameter('receive_timeout')) + ; + + $container->register($this->diUtils->format('processor_registry'), ContainerProcessorRegistry::class); + + $this->addServiceToLocator($container, 'queue_consumer'); + $this->addServiceToLocator($container, 'processor_registry'); + + if ($this->default) { + $container->setAlias(QueueConsumerInterface::class, $this->diUtils->format('queue_consumer')); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('queue_consumer'), $this->diUtils->format('queue_consumer')); + } + } + } + + public function buildRpcClient(ContainerBuilder $container, array $config): void + { + $contextId = $this->diUtils->format('context'); + $this->assertServiceExists($container, $contextId); + + $container->register($this->diUtils->format('rpc_factory'), RpcFactory::class) + ->addArgument(new Reference($contextId)) + ; + + $container->register($this->diUtils->format('rpc_client'), RpcClient::class) + ->addArgument(new Reference($contextId)) + ->addArgument(new Reference($this->diUtils->format('rpc_factory'))) + ; + + if ($this->default) { + $container->setAlias(RpcClient::class, $this->diUtils->format('rpc_client')); + + if (DiUtils::DEFAULT_CONFIG !== $this->diUtils->getConfigName()) { + $container->setAlias($this->diUtils->formatDefault('rpc_client'), $this->diUtils->format('rpc_client')); + } + } + } + + private function assertServiceExists(ContainerBuilder $container, string $serviceId): void + { + if (false == $container->hasDefinition($serviceId)) { + throw new \InvalidArgumentException(sprintf('The service "%s" does not exist.', $serviceId)); + } + } + + private function addServiceToLocator(ContainerBuilder $container, string $serviceName): void + { + $locatorId = 'enqueue.locator'; + + if ($container->hasDefinition($locatorId)) { + $locator = $container->getDefinition($locatorId); + + $map = $locator->getArgument(0); + $map[$this->diUtils->format($serviceName)] = $this->diUtils->reference($serviceName); + + $locator->replaceArgument(0, $map); + } + } +} diff --git a/pkg/enqueue/Symfony/DiUtils.php b/pkg/enqueue/Symfony/DiUtils.php new file mode 100644 index 000000000..be45287be --- /dev/null +++ b/pkg/enqueue/Symfony/DiUtils.php @@ -0,0 +1,81 @@ +moduleName = $moduleName; + $this->configName = $configName; + } + + public static function create(string $moduleName, string $configName): self + { + return new self($moduleName, $configName); + } + + public function getModuleName(): string + { + return $this->moduleName; + } + + public function getConfigName(): string + { + return $this->configName; + } + + public function reference(string $serviceName, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE): Reference + { + return new Reference($this->format($serviceName), $invalidBehavior); + } + + public function referenceDefault(string $serviceName, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE): Reference + { + return new Reference($this->formatDefault($serviceName), $invalidBehavior); + } + + public function parameter(string $serviceName): string + { + $fullName = $this->format($serviceName); + + return "%$fullName%"; + } + + public function parameterDefault(string $serviceName): string + { + $fullName = $this->formatDefault($serviceName); + + return "%$fullName%"; + } + + public function format(string $serviceName): string + { + return $this->doFormat($this->moduleName, $this->configName, $serviceName); + } + + public function formatDefault(string $serviceName): string + { + return $this->doFormat($this->moduleName, self::DEFAULT_CONFIG, $serviceName); + } + + private function doFormat(string $moduleName, string $configName, string $serviceName): string + { + return sprintf('enqueue.%s.%s.%s', $moduleName, $configName, $serviceName); + } +} diff --git a/pkg/enqueue/Symfony/MissingComponentFactory.php b/pkg/enqueue/Symfony/MissingComponentFactory.php new file mode 100644 index 000000000..94fcb8339 --- /dev/null +++ b/pkg/enqueue/Symfony/MissingComponentFactory.php @@ -0,0 +1,42 @@ +info($message) + ->beforeNormalization() + ->always(function () { + return []; + }) + ->end() + ->validate() + ->always(function () use ($message) { + throw new \InvalidArgumentException($message); + }) + ->end() + ; + + return $node; + } +} diff --git a/pkg/enqueue/Symfony/NullTransportFactory.php b/pkg/enqueue/Symfony/NullTransportFactory.php deleted file mode 100644 index 3da667302..000000000 --- a/pkg/enqueue/Symfony/NullTransportFactory.php +++ /dev/null @@ -1,71 +0,0 @@ -name = $name; - } - - /** - * {@inheritdoc} - */ - public function addConfiguration(ArrayNodeDefinition $builder) - { - } - - /** - * {@inheritdoc} - */ - public function createContext(ContainerBuilder $container, array $config) - { - $contextId = sprintf('enqueue.transport.%s.context', $this->getName()); - $context = new Definition(NullContext::class); - - $container->setDefinition($contextId, $context); - - return $contextId; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $driver = new Definition(NullDriver::class); - $driver->setArguments([ - new Reference(sprintf('enqueue.transport.%s.context', $this->getName())), - new Reference('enqueue.client.config'), - ]); - - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $container->setDefinition($driverId, $driver); - - return $driverId; - } - - /** - * @return string - */ - public function getName() - { - return $this->name; - } -} diff --git a/pkg/enqueue/Symfony/TransportFactoryInterface.php b/pkg/enqueue/Symfony/TransportFactoryInterface.php deleted file mode 100644 index 963fd01b7..000000000 --- a/pkg/enqueue/Symfony/TransportFactoryInterface.php +++ /dev/null @@ -1,35 +0,0 @@ -assertClassImplements(ProcessorRegistryInterface::class, ArrayProcessorRegistry::class); } - public function testCouldBeConstructedWithoutAnyArgument() - { - new ArrayProcessorRegistry(); - } - public function testShouldThrowExceptionIfProcessorIsNotSet() { $registry = new ArrayProcessorRegistry(); @@ -50,7 +47,7 @@ public function testShouldAllowGetProcessorAddedViaAddMethod() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor + * @return MockObject|Processor */ protected function createProcessorMock() { diff --git a/pkg/enqueue/Tests/Client/ChainExtensionTest.php b/pkg/enqueue/Tests/Client/ChainExtensionTest.php new file mode 100644 index 000000000..0f42bcf18 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ChainExtensionTest.php @@ -0,0 +1,155 @@ +assertClassImplements(ExtensionInterface::class, ChainExtension::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(ChainExtension::class); + } + + public function testThrowIfArrayContainsNotExtension() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Invalid extension given'); + + new ChainExtension([$this->createExtension(), new \stdClass()]); + } + + public function testShouldProxyOnPreSendEventToAllInternalExtensions() + { + $preSend = new PreSend( + 'aCommandOrTopic', + new Message(), + $this->createMock(ProducerInterface::class), + $this->createMock(DriverInterface::class) + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPreSendEvent') + ->with($this->identicalTo($preSend)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPreSendEvent') + ->with($this->identicalTo($preSend)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPreSendEvent($preSend); + } + + public function testShouldProxyOnPreSendCommandToAllInternalExtensions() + { + $preSend = new PreSend( + 'aCommandOrTopic', + new Message(), + $this->createMock(ProducerInterface::class), + $this->createMock(DriverInterface::class) + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPreSendCommand') + ->with($this->identicalTo($preSend)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPreSendCommand') + ->with($this->identicalTo($preSend)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPreSendCommand($preSend); + } + + public function testShouldProxyOnDriverPreSendToAllInternalExtensions() + { + $driverPreSend = new DriverPreSend( + new Message(), + $this->createMock(ProducerInterface::class), + $this->createMock(DriverInterface::class) + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onDriverPreSend') + ->with($this->identicalTo($driverPreSend)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onDriverPreSend') + ->with($this->identicalTo($driverPreSend)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onDriverPreSend($driverPreSend); + } + + public function testShouldProxyOnPostSentToAllInternalExtensions() + { + $postSend = new PostSend( + new Message(), + $this->createMock(ProducerInterface::class), + $this->createMock(DriverInterface::class), + $this->createMock(Destination::class), + $this->createMock(TransportMessage::class) + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPostSend') + ->with($this->identicalTo($postSend)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPostSend') + ->with($this->identicalTo($postSend)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPostSend($postSend); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ExtensionInterface + */ + protected function createExtension() + { + return $this->createMock(ExtensionInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/ConfigTest.php b/pkg/enqueue/Tests/Client/ConfigTest.php index 7d2c0a499..09b80e2c1 100644 --- a/pkg/enqueue/Tests/Client/ConfigTest.php +++ b/pkg/enqueue/Tests/Client/ConfigTest.php @@ -3,100 +3,250 @@ namespace Enqueue\Tests\Client; use Enqueue\Client\Config; +use PHPUnit\Framework\TestCase; -class ConfigTest extends \PHPUnit_Framework_TestCase +class ConfigTest extends TestCase { - public function testShouldReturnRouterProcessorNameSetInConstructor() + public function testShouldReturnPrefixSetInConstructor() { $config = new Config( - 'aPrefix', + 'thePrefix', + 'theSeparator', 'aApp', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aRouterProcessorName', $config->getRouterProcessorName()); + $this->assertEquals('thePrefix', $config->getPrefix()); } - public function testShouldReturnRouterTopicNameSetInConstructor() + /** + * @dataProvider provideEmptyStrings + */ + public function testShouldTrimReturnPrefixSetInConstructor(string $empty) { $config = new Config( - 'aPrefix', + $empty, 'aApp', + 'theSeparator', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aRouterTopicName', $config->getRouterTopicName()); + $this->assertSame('', $config->getPrefix()); } - public function testShouldReturnRouterQueueNameSetInConstructor() + public function testShouldReturnAppNameSetInConstructor() + { + $config = new Config( + 'aPrefix', + 'theSeparator', + 'theApp', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueueName', + 'aRouterProcessorName', + [], + [] + ); + + $this->assertEquals('theApp', $config->getApp()); + } + + /** + * @dataProvider provideEmptyStrings + */ + public function testShouldTrimReturnAppNameSetInConstructor(string $empty) { $config = new Config( 'aPrefix', + 'theSeparator', + $empty, + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueueName', + 'aRouterProcessorName', + [], + [] + ); + + $this->assertSame('', $config->getApp()); + } + + public function testShouldReturnRouterProcessorNameSetInConstructor() + { + $config = new Config( + 'aPrefix', + 'theSeparator', 'aApp', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aRouterQueueName', $config->getRouterQueueName()); + $this->assertEquals('aRouterProcessorName', $config->getRouterProcessor()); } - public function testShouldReturnDefaultQueueNameSetInConstructor() + public function testShouldReturnRouterTopicNameSetInConstructor() { $config = new Config( 'aPrefix', + 'theSeparator', 'aApp', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aDefaultQueueName', $config->getDefaultProcessorQueueName()); + $this->assertEquals('aRouterTopicName', $config->getRouterTopic()); } - public function testShouldCreateRouterTopicName() + public function testShouldReturnRouterQueueNameSetInConstructor() { $config = new Config( 'aPrefix', + 'theSeparator', 'aApp', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aprefix.aname', $config->createTransportRouterTopicName('aName')); + $this->assertEquals('aRouterQueueName', $config->getRouterQueue()); } - public function testShouldCreateProcessorQueueName() + public function testShouldReturnDefaultQueueNameSetInConstructor() { $config = new Config( 'aPrefix', + 'theSeparator', 'aApp', 'aRouterTopicName', 'aRouterQueueName', 'aDefaultQueueName', - 'aRouterProcessorName' + 'aRouterProcessorName', + [], + [] ); - $this->assertEquals('aprefix.aapp.aname', $config->createTransportQueueName('aName')); + $this->assertEquals('aDefaultQueueName', $config->getDefaultQueue()); } public function testShouldCreateDefaultConfig() { $config = Config::create(); - $this->assertSame('default', $config->getDefaultProcessorQueueName()); - $this->assertSame('router', $config->getRouterProcessorName()); - $this->assertSame('default', $config->getRouterQueueName()); - $this->assertSame('router', $config->getRouterTopicName()); + $this->assertSame('default', $config->getDefaultQueue()); + $this->assertSame('router', $config->getRouterProcessor()); + $this->assertSame('default', $config->getRouterQueue()); + $this->assertSame('router', $config->getRouterTopic()); + } + + /** + * @dataProvider provideEmptyStrings + */ + public function testThrowIfRouterTopicNameIsEmpty(string $empty) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Router topic is empty.'); + new Config( + '', + '', + '', + $empty, + 'aRouterQueueName', + 'aDefaultQueueName', + 'aRouterProcessorName', + [], + [] + ); + } + + /** + * @dataProvider provideEmptyStrings + */ + public function testThrowIfRouterQueueNameIsEmpty(string $empty) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Router queue is empty.'); + new Config( + '', + '', + '', + 'aRouterTopicName', + $empty, + 'aDefaultQueueName', + 'aRouterProcessorName', + [], + [] + ); + } + + /** + * @dataProvider provideEmptyStrings + */ + public function testThrowIfDefaultQueueNameIsEmpty(string $empty) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Default processor queue name is empty.'); + new Config( + '', + '', + '', + 'aRouterTopicName', + 'aRouterQueueName', + $empty, + 'aRouterProcessorName', + [], + [] + ); + } + + /** + * @dataProvider provideEmptyStrings + */ + public function testThrowIfRouterProcessorNameIsEmpty(string $empty) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Router processor name is empty.'); + new Config( + '', + '', + '', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueueName', + $empty, + [], + [] + ); + } + + public function provideEmptyStrings() + { + yield ['']; + + yield [' ']; + + yield [' ']; + + yield ["\t"]; } } diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/DelayRedeliveredMessageExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/DelayRedeliveredMessageExtensionTest.php index 3e2a99a41..a660126ad 100644 --- a/pkg/enqueue/Tests/Client/ConsumptionExtension/DelayRedeliveredMessageExtensionTest.php +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/DelayRedeliveredMessageExtensionTest.php @@ -4,21 +4,25 @@ use Enqueue\Client\ConsumptionExtension\DelayRedeliveredMessageExtension; use Enqueue\Client\DriverInterface; +use Enqueue\Client\DriverSendResult; use Enqueue\Client\Message; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\MessageReceived; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Transport\Null\NullMessage; -use Enqueue\Transport\Null\NullQueue; -use Psr\Log\LoggerInterface; - -class DelayRedeliveredMessageExtensionTest extends \PHPUnit_Framework_TestCase +use Enqueue\Null\NullMessage; +use Enqueue\Null\NullQueue; +use Enqueue\Test\TestLogger; +use Interop\Queue\Consumer; +use Interop\Queue\Context as InteropContext; +use Interop\Queue\Destination; +use Interop\Queue\Message as TransportMessage; +use Interop\Queue\Processor; +use Interop\Queue\Queue; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class DelayRedeliveredMessageExtensionTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new DelayRedeliveredMessageExtension($this->createDriverMock(), 12345); - } - public function testShouldSendDelayedMessageAndRejectOriginalMessage() { $queue = new NullQueue('queue'); @@ -37,6 +41,7 @@ public function testShouldSendDelayedMessageAndRejectOriginalMessage() ->expects(self::once()) ->method('sendToProcessor') ->with(self::isInstanceOf(Message::class)) + ->willReturn($this->createDriverSendResult()) ; $driver ->expects(self::once()) @@ -45,37 +50,41 @@ public function testShouldSendDelayedMessageAndRejectOriginalMessage() ->willReturn($delayedMessage) ; - $logger = $this->createLoggerMock(); - $logger - ->expects(self::at(0)) - ->method('debug') - ->with('[DelayRedeliveredMessageExtension] Send delayed message') - ; - $logger - ->expects(self::at(1)) - ->method('debug') - ->with( - '[DelayRedeliveredMessageExtension] '. - 'Reject redelivered original message by setting reject status to context.' - ) - ; + $logger = new TestLogger(); - $context = new Context($this->createPsrContextMock()); - $context->setPsrQueue($queue); - $context->setPsrMessage($originMessage); - $context->setLogger($logger); + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub($queue), + $originMessage, + $this->createProcessorMock(), + 1, + $logger + ); - self::assertNull($context->getResult()); + $this->assertNull($messageReceived->getResult()); $extension = new DelayRedeliveredMessageExtension($driver, 12345); - $extension->onPreReceived($context); + $extension->onMessageReceived($messageReceived); - self::assertEquals(Result::REJECT, $context->getResult()); + $result = $messageReceived->getResult(); + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::REJECT, $result->getStatus()); + $this->assertSame('A new copy of the message was sent with a delay. The original message is rejected', $result->getReason()); - self::assertInstanceOf(Message::class, $delayedMessage); - self::assertEquals([ + $this->assertInstanceOf(Message::class, $delayedMessage); + $this->assertEquals([ 'enqueue.redelivery_count' => 1, ], $delayedMessage->getProperties()); + + self::assertTrue( + $logger->hasDebugThatContains('[DelayRedeliveredMessageExtension] Send delayed message') + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[DelayRedeliveredMessageExtension] '. + 'Reject redelivered original message by setting reject status to context.' + ) + ); } public function testShouldDoNothingIfMessageIsNotRedelivered() @@ -88,36 +97,90 @@ public function testShouldDoNothingIfMessageIsNotRedelivered() ->method('sendToProcessor') ; - $context = new Context($this->createPsrContextMock()); - $context->setPsrMessage($message); + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); $extension = new DelayRedeliveredMessageExtension($driver, 12345); - $extension->onPreReceived($context); + $extension->onMessageReceived($messageReceived); + + $this->assertNull($messageReceived->getResult()); + } + + public function testShouldDoNothingIfMessageIsRedeliveredButResultWasAlreadySetOnContext() + { + $message = new NullMessage(); + $message->setRedelivered(true); - self::assertNull($context->getResult()); + $driver = $this->createDriverMock(); + $driver + ->expects(self::never()) + ->method('sendToProcessor') + ; + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + $messageReceived->setResult(Result::ack()); + + $extension = new DelayRedeliveredMessageExtension($driver, 12345); + $extension->onMessageReceived($messageReceived); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface + * @return MockObject */ - private function createDriverMock() + private function createDriverMock(): DriverInterface { return $this->createMock(DriverInterface::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|PsrContext + * @return MockObject + */ + private function createContextMock(): InteropContext + { + return $this->createMock(InteropContext::class); + } + + /** + * @return MockObject */ - private function createPsrContextMock() + private function createProcessorMock(): Processor { - return $this->createMock(PsrContext::class); + return $this->createMock(Processor::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface + * @return MockObject|Consumer */ - private function createLoggerMock() + private function createConsumerStub(?Queue $queue): Consumer + { + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue ?? new NullQueue('queue')) + ; + + return $consumerMock; + } + + private function createDriverSendResult(): DriverSendResult { - return $this->createMock(LoggerInterface::class); + return new DriverSendResult( + $this->createMock(Destination::class), + $this->createMock(TransportMessage::class) + ); } } diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/ExclusiveCommandExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/ExclusiveCommandExtensionTest.php new file mode 100644 index 000000000..b1e47c898 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/ExclusiveCommandExtensionTest.php @@ -0,0 +1,286 @@ +assertClassImplements(MessageReceivedExtensionInterface::class, ExclusiveCommandExtension::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(ExclusiveCommandExtension::class); + } + + public function testShouldDoNothingIfMessageHasTopicPropertySetOnPreReceive() + { + $message = new NullMessage(); + $message->setProperty(Config::TOPIC, 'aTopic'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('createQueue') + ; + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $extension = new ExclusiveCommandExtension($driver); + + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([ + Config::TOPIC => 'aTopic', + ], $message->getProperties()); + } + + public function testShouldDoNothingIfMessageHasCommandPropertySetOnPreReceive() + { + $message = new NullMessage(); + $message->setProperty(Config::COMMAND, 'aCommand'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('createQueue') + ; + + $extension = new ExclusiveCommandExtension($driver); + + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([ + Config::COMMAND => 'aCommand', + ], $message->getProperties()); + } + + public function testShouldDoNothingIfMessageHasProcessorPropertySetOnPreReceive() + { + $message = new NullMessage(); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('createQueue') + ; + + $extension = new ExclusiveCommandExtension($driver); + + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([ + Config::PROCESSOR => 'aProcessor', + ], $message->getProperties()); + } + + public function testShouldDoNothingIfCurrentQueueHasNoExclusiveProcessor() + { + $message = new NullMessage(); + $queue = new NullQueue('aBarQueueName'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub($queue), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $extension = new ExclusiveCommandExtension($this->createDriverStub(new RouteCollection([]))); + + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([], $message->getProperties()); + } + + public function testShouldSetCommandPropertiesIfCurrentQueueHasExclusiveCommandProcessor() + { + $message = new NullMessage(); + $queue = new NullQueue('fooQueue'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub($queue), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'theFooProcessor', [ + 'exclusive' => true, + 'queue' => 'fooQueue', + ]), + new Route('barCommand', Route::COMMAND, 'theFooProcessor', [ + 'exclusive' => true, + 'queue' => 'barQueue', + ]), + ]); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->any()) + ->method('createRouteQueue') + ->with($this->isInstanceOf(Route::class)) + ->willReturnCallback(function (Route $route) { + return new NullQueue($route->getQueue()); + }) + ; + + $extension = new ExclusiveCommandExtension($driver); + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([ + Config::PROCESSOR => 'theFooProcessor', + Config::COMMAND => 'fooCommand', + ], $message->getProperties()); + } + + public function testShouldDoNothingIfAnotherQueue() + { + $message = new NullMessage(); + $queue = new NullQueue('barQueue'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub($queue), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'theFooProcessor', [ + 'exclusive' => true, + 'queue' => 'fooQueue', + ]), + new Route('barCommand', Route::COMMAND, 'theFooProcessor', [ + 'exclusive' => false, + 'queue' => 'barQueue', + ]), + ]); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $queueName) { + return new NullQueue($queueName); + }) + ; + + $extension = new ExclusiveCommandExtension($driver); + $extension->onMessageReceived($messageReceived); + + self::assertNull($messageReceived->getResult()); + + $this->assertEquals([], $message->getProperties()); + } + + /** + * @return MockObject|DriverInterface + */ + private function createDriverStub(?RouteCollection $routeCollection = null): DriverInterface + { + $driver = $this->createMock(DriverInterface::class); + $driver + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection ?? new RouteCollection([])) + ; + + return $driver; + } + + /** + * @return MockObject + */ + private function createContextMock(): InteropContext + { + return $this->createMock(InteropContext::class); + } + + /** + * @return MockObject + */ + private function createProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return MockObject|Consumer + */ + private function createConsumerStub(?Queue $queue): Consumer + { + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue ?? new NullQueue('queue')) + ; + + return $consumerMock; + } +} diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/FlushSpoolProducerExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/FlushSpoolProducerExtensionTest.php new file mode 100644 index 000000000..6a782c524 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/FlushSpoolProducerExtensionTest.php @@ -0,0 +1,83 @@ +assertClassImplements(PostMessageReceivedExtensionInterface::class, FlushSpoolProducerExtension::class); + } + + public function testShouldImplementEndExtensionInterface() + { + $this->assertClassImplements(EndExtensionInterface::class, FlushSpoolProducerExtension::class); + } + + public function testShouldFlushSpoolProducerOnEnd() + { + $producer = $this->createSpoolProducerMock(); + $producer + ->expects(self::once()) + ->method('flush') + ; + + $end = new End($this->createInteropContextMock(), 1, 2, new NullLogger()); + + $extension = new FlushSpoolProducerExtension($producer); + $extension->onEnd($end); + } + + public function testShouldFlushSpoolProducerOnPostReceived() + { + $producer = $this->createSpoolProducerMock(); + $producer + ->expects(self::once()) + ->method('flush') + ; + + $context = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); + + $extension = new FlushSpoolProducerExtension($producer); + $extension->onPostMessageReceived($context); + } + + /** + * @return MockObject + */ + private function createInteropContextMock(): Context + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject|SpoolProducer + */ + private function createSpoolProducerMock() + { + return $this->createMock(SpoolProducer::class); + } +} diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/LogExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/LogExtensionTest.php new file mode 100644 index 000000000..db757676b --- /dev/null +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/LogExtensionTest.php @@ -0,0 +1,536 @@ +assertClassImplements(StartExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementEndExtensionInterface() + { + $this->assertClassImplements(EndExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementMessageReceivedExtensionInterface() + { + $this->assertClassImplements(MessageReceivedExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementPostMessageReceivedExtensionInterface() + { + $this->assertClassImplements(PostMessageReceivedExtensionInterface::class, LogExtension::class); + } + + public function testShouldSubClassOfLogExtension() + { + $this->assertClassExtends(\Enqueue\Consumption\Extension\LogExtension::class, LogExtension::class); + } + + public function testShouldLogStartOnStart() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Consumption has started') + ; + + $context = new Start($this->createContextMock(), $logger, [], 1, 1); + + $extension = new LogExtension(); + $extension->onStart($context); + } + + public function testShouldLogEndOnEnd() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Consumption has ended') + ; + + $context = new End($this->createContextMock(), 1, 2, $logger); + + $extension = new LogExtension(); + $extension->onEnd($context); + } + + public function testShouldLogMessageReceived() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Received from {queueName} {body}', [ + 'queueName' => 'aQueue', + 'redelivered' => false, + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + ]) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new MessageReceived($this->createContextMock(), $consumerMock, $message, $this->createProcessorMock(), 1, $logger); + + $extension = new LogExtension(); + $extension->onMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithStringResult() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'aResult', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, 'aResult', 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogRejectedMessageAsError() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'reject', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Processor::REJECT, 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack(), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithReasonResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result} {reason}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => 'aReason', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack('aReason'), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedCommandMessageWithStringResult() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {command} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::COMMAND => 'aCommand']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'aResult', + 'reason' => '', + 'command' => 'aCommand', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty(Config::COMMAND, 'aCommand'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, 'aResult', 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogRejectedCommandMessageAsError() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, + '[client] Processed {command} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::COMMAND => 'aCommand']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'reject', + 'reason' => '', + 'command' => 'aCommand', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setProperty(Config::COMMAND, 'aCommand'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Processor::REJECT, 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedCommandMessageWithResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {command} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::COMMAND => 'aCommand']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => '', + 'command' => 'aCommand', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setProperty(Config::COMMAND, 'aCommand'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack(), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedCommandMessageWithReasonResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {command} {body} {result} {reason}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::COMMAND => 'aCommand']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => 'aReason', + 'command' => 'aCommand', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setProperty(Config::COMMAND, 'aCommand'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack('aReason'), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedTopicProcessorMessageWithStringResult() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {topic} -> {processor} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::TOPIC => 'aTopic', Config::PROCESSOR => 'aProcessor']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'aResult', + 'reason' => '', + 'topic' => 'aTopic', + 'processor' => 'aProcessor', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty(Config::TOPIC, 'aTopic'); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, 'aResult', 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogRejectedTopicProcessorMessageAsError() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, + '[client] Processed {topic} -> {processor} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::TOPIC => 'aTopic', Config::PROCESSOR => 'aProcessor']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'reject', + 'reason' => '', + 'topic' => 'aTopic', + 'processor' => 'aProcessor', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty(Config::TOPIC, 'aTopic'); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Processor::REJECT, 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedTopicProcessorMessageWithResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {topic} -> {processor} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::TOPIC => 'aTopic', Config::PROCESSOR => 'aProcessor']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => '', + 'topic' => 'aTopic', + 'processor' => 'aProcessor', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty(Config::TOPIC, 'aTopic'); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack(), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogProcessedTopicProcessorMessageWithReasonResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + '[client] Processed {topic} -> {processor} {body} {result} {reason}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal', Config::TOPIC => 'aTopic', Config::PROCESSOR => 'aProcessor']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => 'aReason', + 'topic' => 'aTopic', + 'processor' => 'aProcessor', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty(Config::TOPIC, 'aTopic'); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack('aReason'), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + /** + * @return MockObject + */ + private function createConsumerStub(Queue $queue): Consumer + { + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue) + ; + + return $consumerMock; + } + + /** + * @return MockObject + */ + private function createContextMock(): Context + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject + */ + private function createProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return MockObject|LoggerInterface + */ + private function createLogger() + { + return $this->createMock(LoggerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/SetRouterPropertiesExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/SetRouterPropertiesExtensionTest.php index df4de33b3..d521aefca 100644 --- a/pkg/enqueue/Tests/Client/ConsumptionExtension/SetRouterPropertiesExtensionTest.php +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/SetRouterPropertiesExtensionTest.php @@ -5,48 +5,102 @@ use Enqueue\Client\Config; use Enqueue\Client\ConsumptionExtension\SetRouterPropertiesExtension; use Enqueue\Client\DriverInterface; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\ExtensionInterface; -use Enqueue\Psr\Context as PsrContext; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\MessageReceivedExtensionInterface; +use Enqueue\Null\NullMessage; +use Enqueue\Null\NullQueue; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullMessage; - -class SetRouterPropertiesExtensionTest extends \PHPUnit_Framework_TestCase +use Interop\Queue\Consumer; +use Interop\Queue\Context as InteropContext; +use Interop\Queue\Processor; +use Interop\Queue\Queue; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class SetRouterPropertiesExtensionTest extends TestCase { use ClassExtensionTrait; - public function testShouldImplementExtensionInterface() + public function testShouldImplementMessageReceivedExtensionInterface() { - $this->assertClassImplements(ExtensionInterface::class, SetRouterPropertiesExtension::class); + $this->assertClassImplements(MessageReceivedExtensionInterface::class, SetRouterPropertiesExtension::class); } - public function testCouldBeConstructedWithRequiredArguments() + public function testShouldSetRouterProcessorPropertyIfNotSetAndOnRouterQueue() { - new SetRouterPropertiesExtension($this->createDriverMock()); + $config = Config::create('test', '.', '', '', 'router-queue', '', 'router-processor-name'); + $queue = new NullQueue('test.router-queue'); + + $driver = $this->createDriverMock(); + $driver + ->expects($this->once()) + ->method('getConfig') + ->willReturn($config) + ; + + $driver + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + + $message = new NullMessage(); + $message->setProperty(Config::TOPIC, 'aTopic'); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(new NullQueue('test.router-queue')), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $extension = new SetRouterPropertiesExtension($driver); + $extension->onMessageReceived($messageReceived); + + $this->assertEquals([ + Config::PROCESSOR => 'router-processor-name', + Config::TOPIC => 'aTopic', + ], $message->getProperties()); } - public function testShouldSetRouterProcessorPropertyIfNotSet() + public function testShouldNotSetRouterProcessorPropertyIfNotSetAndNotOnRouterQueue() { - $config = new Config('', '', '', 'router-queue', '', 'router-processor-name'); + $config = Config::create('test', '', '', 'router-queue', '', 'router-processor-name'); + $queue = new NullQueue('test.router-queue'); $driver = $this->createDriverMock(); $driver - ->expects(self::exactly(2)) + ->expects($this->once()) ->method('getConfig') ->willReturn($config) ; + $driver + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + $message = new NullMessage(); + $message->setProperty(Config::TOPIC, 'aTopic'); - $context = new Context($this->createPsrContextMock()); - $context->setPsrMessage($message); + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(new NullQueue('test.another-queue')), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); $extension = new SetRouterPropertiesExtension($driver); - $extension->onPreReceived($context); + $extension->onMessageReceived($messageReceived); $this->assertEquals([ - 'enqueue.processor_name' => 'router-processor-name', - 'enqueue.processor_queue_name' => 'router-queue', + Config::TOPIC => 'aTopic', ], $message->getProperties()); } @@ -54,37 +108,91 @@ public function testShouldNotSetAnyPropertyIfProcessorNamePropertyAlreadySet() { $driver = $this->createDriverMock(); $driver - ->expects(self::never()) + ->expects($this->never()) ->method('getConfig') ; $message = new NullMessage(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'non-router-processor'); + $message->setProperty(Config::PROCESSOR, 'non-router-processor'); - $context = new Context($this->createPsrContextMock()); - $context->setPsrMessage($message); + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); $extension = new SetRouterPropertiesExtension($driver); - $extension->onPreReceived($context); + $extension->onMessageReceived($messageReceived); $this->assertEquals([ - 'enqueue.processor_name' => 'non-router-processor', + 'enqueue.processor' => 'non-router-processor', ], $message->getProperties()); } + public function testShouldSkipMessagesWithoutTopicPropertySet() + { + $driver = $this->createDriverMock(); + $driver + ->expects($this->never()) + ->method('getConfig') + ; + + $message = new NullMessage(); + + $messageReceived = new MessageReceived( + $this->createContextMock(), + $this->createConsumerStub(null), + $message, + $this->createProcessorMock(), + 1, + new NullLogger() + ); + + $extension = new SetRouterPropertiesExtension($driver); + $extension->onMessageReceived($messageReceived); + + $this->assertEquals([], $message->getProperties()); + } + /** - * @return \PHPUnit_Framework_MockObject_MockObject|PsrContext + * @return MockObject|InteropContext */ - protected function createPsrContextMock() + protected function createContextMock(): InteropContext { - return $this->createMock(PsrContext::class); + return $this->createMock(InteropContext::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface + * @return MockObject|DriverInterface */ - protected function createDriverMock() + protected function createDriverMock(): DriverInterface { return $this->createMock(DriverInterface::class); } + + /** + * @return MockObject + */ + private function createProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return MockObject|Consumer + */ + private function createConsumerStub(?Queue $queue): Consumer + { + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue ?? new NullQueue('queue')) + ; + + return $consumerMock; + } } diff --git a/pkg/enqueue/Tests/Client/ConsumptionExtension/SetupBrokerExtensionTest.php b/pkg/enqueue/Tests/Client/ConsumptionExtension/SetupBrokerExtensionTest.php index 8cd460258..fbd367975 100644 --- a/pkg/enqueue/Tests/Client/ConsumptionExtension/SetupBrokerExtensionTest.php +++ b/pkg/enqueue/Tests/Client/ConsumptionExtension/SetupBrokerExtensionTest.php @@ -4,29 +4,26 @@ use Enqueue\Client\ConsumptionExtension\SetupBrokerExtension; use Enqueue\Client\DriverInterface; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\ExtensionInterface; -use Enqueue\Psr\Context as PsrContext; +use Enqueue\Consumption\Context\Start; +use Enqueue\Consumption\StartExtensionInterface; use Enqueue\Test\ClassExtensionTrait; +use Interop\Queue\Context as InteropContext; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; -class SetupBrokerExtensionTest extends \PHPUnit_Framework_TestCase +class SetupBrokerExtensionTest extends TestCase { use ClassExtensionTrait; - public function testShouldImplementExtensionInterface() + public function testShouldImplementStartExtensionInterface() { - $this->assertClassImplements(ExtensionInterface::class, SetupBrokerExtension::class); - } - - public function testCouldBeConstructedWithRequiredArguments() - { - new SetupBrokerExtension($this->createDriverMock()); + $this->assertClassImplements(StartExtensionInterface::class, SetupBrokerExtension::class); } public function testShouldSetupBroker() { - $logger = new NullLogger(''); + $logger = new NullLogger(); $driver = $this->createDriverMock(); $driver @@ -35,8 +32,7 @@ public function testShouldSetupBroker() ->with($this->identicalTo($logger)) ; - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($logger); + $context = new Start($this->createMock(InteropContext::class), $logger, [], 0, 0); $extension = new SetupBrokerExtension($driver); $extension->onStart($context); @@ -44,7 +40,7 @@ public function testShouldSetupBroker() public function testShouldSetupBrokerOnlyOnce() { - $logger = new NullLogger(''); + $logger = new NullLogger(); $driver = $this->createDriverMock(); $driver @@ -53,8 +49,7 @@ public function testShouldSetupBrokerOnlyOnce() ->with($this->identicalTo($logger)) ; - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($logger); + $context = new Start($this->createMock(InteropContext::class), $logger, [], 0, 0); $extension = new SetupBrokerExtension($driver); $extension->onStart($context); @@ -62,7 +57,7 @@ public function testShouldSetupBrokerOnlyOnce() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface + * @return MockObject|DriverInterface */ private function createDriverMock() { diff --git a/pkg/enqueue/Tests/Client/DelegateProcessorTest.php b/pkg/enqueue/Tests/Client/DelegateProcessorTest.php index 6a6f466a6..9743cf4f3 100644 --- a/pkg/enqueue/Tests/Client/DelegateProcessorTest.php +++ b/pkg/enqueue/Tests/Client/DelegateProcessorTest.php @@ -4,35 +4,30 @@ use Enqueue\Client\Config; use Enqueue\Client\DelegateProcessor; -use Enqueue\Client\ProcessorRegistryInterface; -use Enqueue\Psr\Context; -use Enqueue\Psr\Processor; -use Enqueue\Transport\Null\NullMessage; +use Enqueue\Null\NullMessage; +use Enqueue\ProcessorRegistryInterface; +use Interop\Queue\Context; +use Interop\Queue\Processor; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; -class DelegateProcessorTest extends \PHPUnit_Framework_TestCase +class DelegateProcessorTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new DelegateProcessor($this->createProcessorRegistryMock()); - } - public function testShouldThrowExceptionIfProcessorNameIsNotSet() { - $this->setExpectedException( - \LogicException::class, - 'Got message without required parameter: "enqueue.processor_name"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Got message without required parameter: "enqueue.processor"'); $processor = new DelegateProcessor($this->createProcessorRegistryMock()); - $processor->process(new NullMessage(), $this->createPsrContextMock()); + $processor->process(new NullMessage(), $this->createContextMock()); } public function testShouldProcessMessage() { - $session = $this->createPsrContextMock(); + $session = $this->createContextMock(); $message = new NullMessage(); $message->setProperties([ - Config::PARAMETER_PROCESSOR_NAME => 'processor-name', + Config::PROCESSOR => 'processor-name', ]); $processor = $this->createProcessorMock(); @@ -40,7 +35,7 @@ public function testShouldProcessMessage() ->expects($this->once()) ->method('process') ->with($this->identicalTo($message), $this->identicalTo($session)) - ->will($this->returnValue('return-value')) + ->willReturn('return-value') ; $processorRegistry = $this->createProcessorRegistryMock(); @@ -48,7 +43,7 @@ public function testShouldProcessMessage() ->expects($this->once()) ->method('get') ->with('processor-name') - ->will($this->returnValue($processor)) + ->willReturn($processor) ; $processor = new DelegateProcessor($processorRegistry); @@ -58,7 +53,7 @@ public function testShouldProcessMessage() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|ProcessorRegistryInterface + * @return MockObject|ProcessorRegistryInterface */ protected function createProcessorRegistryMock() { @@ -66,15 +61,15 @@ protected function createProcessorRegistryMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context + * @return MockObject|Context */ - protected function createPsrContextMock() + protected function createContextMock() { return $this->createMock(Context::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor + * @return MockObject|Processor */ protected function createProcessorMock() { diff --git a/pkg/enqueue/Tests/Client/Driver/AmqpDriverTest.php b/pkg/enqueue/Tests/Client/Driver/AmqpDriverTest.php new file mode 100644 index 000000000..2cfb170b9 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/AmqpDriverTest.php @@ -0,0 +1,360 @@ +assertClassImplements(DriverInterface::class, AmqpDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, AmqpDriver::class); + } + + public function testThrowIfPriorityIsNotSupportedOnCreateTransportMessage() + { + $clientMessage = new Message(); + $clientMessage->setPriority('invalidPriority'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($this->createMessage()) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cant convert client priority "invalidPriority" to transport one. Could be one of "enqueue.message_queue.client.very_low_message_priority", "enqueue.message_queue.client.low_message_priority", "enqueue.message_queue.client.normal_message_priority'); + $driver->createTransportMessage($clientMessage); + } + + public function testShouldSetExpirationHeaderFromClientMessageExpireInMillisecondsOnCreateTransportMessage() + { + $clientMessage = new Message(); + $clientMessage->setExpire(333); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($this->createMessage()) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var AmqpMessage $transportMessage */ + $transportMessage = $driver->createTransportMessage($clientMessage); + + $this->assertSame(333000, $transportMessage->getExpiration()); + $this->assertSame('333000', $transportMessage->getHeader('expiration')); + } + + public function testShouldSetPersistedDeliveryModeOnCreateTransportMessage() + { + $clientMessage = new Message(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($this->createMessage()) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var AmqpMessage $transportMessage */ + $transportMessage = $driver->createTransportMessage($clientMessage); + + $this->assertSame(AmqpMessage::DELIVERY_MODE_PERSISTENT, $transportMessage->getDeliveryMode()); + } + + public function testShouldCreateDurableQueue() + { + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($this->createQueue('aName')) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var AmqpQueue $queue */ + $queue = $driver->createQueue('aName'); + + $this->assertSame(AmqpQueue::FLAG_DURABLE, $queue->getFlags()); + } + + public function testShouldResetPriorityAndExpirationAndNeverCallProducerDeliveryDelayOnSendMessageToRouter() + { + $topic = $this->createTopic(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($topic), $this->identicalTo($transportMessage)) + ; + $producer + ->expects($this->never()) + ->method('setDeliveryDelay') + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createTopic') + ->willReturn($topic) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setExpire(123); + $message->setPriority(MessagePriority::HIGH); + + $driver->sendToRouter($message); + + $this->assertNull($transportMessage->getExpiration()); + $this->assertNull($transportMessage->getPriority()); + } + + public function testShouldSetupBroker() + { + $routerTopic = $this->createTopic(''); + $routerQueue = $this->createQueue(''); + $processorWithDefaultQueue = $this->createQueue('default'); + $processorWithCustomQueue = $this->createQueue('custom'); + $context = $this->createContextMock(); + // setup router + $context + ->expects($this->at(0)) + ->method('createTopic') + ->willReturn($routerTopic) + ; + $context + ->expects($this->at(1)) + ->method('declareTopic') + ->with($this->identicalTo($routerTopic)) + ; + + $context + ->expects($this->at(2)) + ->method('createQueue') + ->willReturn($routerQueue) + ; + $context + ->expects($this->at(3)) + ->method('declareQueue') + ->with($this->identicalTo($routerQueue)) + ; + + $context + ->expects($this->at(4)) + ->method('bind') + ->with($this->isInstanceOf(AmqpBind::class)) + ; + + // setup processor with default queue + $context + ->expects($this->at(5)) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($processorWithDefaultQueue) + ; + $context + ->expects($this->at(6)) + ->method('declareQueue') + ->with($this->identicalTo($processorWithDefaultQueue)) + ; + + $context + ->expects($this->at(7)) + ->method('createQueue') + ->with($this->getCustomQueueTransportName()) + ->willReturn($processorWithCustomQueue) + ; + $context + ->expects($this->at(8)) + ->method('declareQueue') + ->with($this->identicalTo($processorWithCustomQueue)) + ; + + $driver = new AmqpDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('aTopic', Route::TOPIC, 'aProcessor'), + new Route('aCommand', Route::COMMAND, 'aProcessor', ['queue' => 'custom']), + ]) + ); + $driver->setupBroker(); + } + + public function testShouldNotDeclareSameQueues() + { + $context = $this->createContextMock(); + + // setup processor with default queue + $context + ->expects($this->any()) + ->method('createTopic') + ->willReturn($this->createTopic('')) + ; + $context + ->expects($this->any()) + ->method('createQueue') + ->willReturn($this->createQueue('custom')) + ; + $context + ->expects($this->exactly(2)) + ->method('declareQueue') + ; + + $driver = new AmqpDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('aTopic', Route::TOPIC, 'aProcessor', ['queue' => 'custom']), + new Route('aCommand', Route::COMMAND, 'aProcessor', ['queue' => 'custom']), + ]) + ); + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new AmqpDriver(...$args); + } + + /** + * @return AmqpContext + */ + protected function createContextMock(): Context + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return AmqpProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(AmqpProducer::class); + } + + /** + * @return AmqpQueue + */ + protected function createQueue(string $name): InteropQueue + { + return new AmqpQueue($name); + } + + protected function createTopic(string $name): AmqpTopic + { + return new AmqpTopic($name); + } + + /** + * @return AmqpMessage + */ + protected function createMessage(): InteropMessage + { + return new AmqpMessage(); + } + + protected function getRouterTransportName(): string + { + return 'aprefix.router'; + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + Assert::assertArraySubset([ + 'hkey' => 'hval', + 'delivery_mode' => AmqpMessage::DELIVERY_MODE_PERSISTENT, + 'content_type' => 'ContentType', + 'expiration' => '123000', + 'priority' => 3, + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply_to' => 'theReplyTo', + 'correlation_id' => 'theCorrelationId', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/DbalDriverTest.php b/pkg/enqueue/Tests/Client/Driver/DbalDriverTest.php new file mode 100644 index 000000000..554a399f9 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/DbalDriverTest.php @@ -0,0 +1,101 @@ +assertClassImplements(DriverInterface::class, DbalDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, DbalDriver::class); + } + + public function testShouldSetupBroker() + { + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getTableName') + ; + $context + ->expects($this->once()) + ->method('createDataBaseTable') + ; + + $driver = new DbalDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new DbalDriver(...$args); + } + + /** + * @return DbalContext + */ + protected function createContextMock(): Context + { + return $this->createMock(DbalContext::class); + } + + /** + * @return DbalProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(DbalProducer::class); + } + + /** + * @return DbalDestination + */ + protected function createQueue(string $name): InteropQueue + { + return new DbalDestination($name); + } + + /** + * @return DbalDestination + */ + protected function createTopic(string $name): InteropTopic + { + return new DbalDestination($name); + } + + /** + * @return DbalMessage + */ + protected function createMessage(): InteropMessage + { + return new DbalMessage(); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/FsDriverTest.php b/pkg/enqueue/Tests/Client/Driver/FsDriverTest.php new file mode 100644 index 000000000..f1cd02f9b --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/FsDriverTest.php @@ -0,0 +1,125 @@ +assertClassImplements(DriverInterface::class, FsDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, FsDriver::class); + } + + public function testShouldSetupBroker() + { + $routerQueue = new FsDestination(TempFile::generate()); + + $processorQueue = new FsDestination(TempFile::generate()); + + $context = $this->createContextMock(); + // setup router + $context + ->expects($this->at(0)) + ->method('createQueue') + ->willReturn($routerQueue) + ; + $context + ->expects($this->at(1)) + ->method('declareDestination') + ->with($this->identicalTo($routerQueue)) + ; + // setup processor queue + $context + ->expects($this->at(2)) + ->method('createQueue') + ->willReturn($processorQueue) + ; + $context + ->expects($this->at(3)) + ->method('declareDestination') + ->with($this->identicalTo($processorQueue)) + ; + + $routeCollection = new RouteCollection([ + new Route('aTopic', Route::TOPIC, 'aProcessor'), + ]); + + $driver = new FsDriver( + $context, + $this->createDummyConfig(), + $routeCollection + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new FsDriver(...$args); + } + + /** + * @return FsContext + */ + protected function createContextMock(): Context + { + return $this->createMock(FsContext::class); + } + + /** + * @return FsProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(FsProducer::class); + } + + /** + * @return FsDestination + */ + protected function createQueue(string $name): InteropQueue + { + return new FsDestination(new \SplFileInfo($name)); + } + + /** + * @return FsDestination + */ + protected function createTopic(string $name): InteropTopic + { + return new FsDestination(new \SplFileInfo($name)); + } + + /** + * @return FsMessage + */ + protected function createMessage(): InteropMessage + { + return new FsMessage(); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/GenericDriverTest.php b/pkg/enqueue/Tests/Client/Driver/GenericDriverTest.php new file mode 100644 index 000000000..78f7f6e83 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/GenericDriverTest.php @@ -0,0 +1,83 @@ +assertClassImplements(DriverInterface::class, GenericDriver::class); + } + + protected function createDriver(...$args): DriverInterface + { + return new GenericDriver(...$args); + } + + protected function createContextMock(): Context + { + return $this->createMock(Context::class); + } + + protected function createProducerMock(): InteropProducer + { + return $this->createMock(InteropProducer::class); + } + + protected function createQueue(string $name): InteropQueue + { + return new NullQueue($name); + } + + protected function createTopic(string $name): InteropTopic + { + return new NullTopic($name); + } + + protected function createMessage(): InteropMessage + { + return new NullMessage(); + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + Assert::assertArraySubset([ + 'hkey' => 'hval', + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply_to' => 'theReplyTo', + 'correlation_id' => 'theCorrelationId', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/GenericDriverTestsTrait.php b/pkg/enqueue/Tests/Client/Driver/GenericDriverTestsTrait.php new file mode 100644 index 000000000..d5ad498a9 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/GenericDriverTestsTrait.php @@ -0,0 +1,1249 @@ +createDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $this->assertInstanceOf(DriverInterface::class, $driver); + } + + public function testShouldReturnContextSetInConstructor() + { + $context = $this->createContextMock(); + + $driver = $this->createDriver($context, $this->createDummyConfig(), new RouteCollection([])); + + $this->assertSame($context, $driver->getContext()); + } + + public function testShouldReturnConfigObjectSetInConstructor() + { + $config = $this->createDummyConfig(); + + $driver = $this->createDriver($this->createContextMock(), $config, new RouteCollection([])); + + $this->assertSame($config, $driver->getConfig()); + } + + public function testShouldReturnRouteCollectionSetInConstructor() + { + $routeCollection = new RouteCollection([]); + + /** @var DriverInterface $driver */ + $driver = $this->createDriver($this->createContextMock(), $this->createDummyConfig(), $routeCollection); + + $this->assertSame($routeCollection, $driver->getRouteCollection()); + } + + public function testShouldCreateAndReturnQueueInstanceWithPrefixAndAppName() + { + $expectedQueue = $this->createQueue('aName'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getPrefixAppFooQueueTransportName()) + ->willReturn($expectedQueue) + ; + + $config = new Config( + 'aPrefix', + '.', + 'anAppName', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueue', + 'aRouterProcessor', + [], + [] + ); + + $driver = $this->createDriver($context, $config, new RouteCollection([])); + + $queue = $driver->createQueue('aFooQueue'); + + $this->assertSame($expectedQueue, $queue); + } + + public function testShouldCreateAndReturnQueueInstanceWithPrefixWithoutAppName() + { + $expectedQueue = $this->createQueue('aName'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getPrefixFooQueueTransportName()) + ->willReturn($expectedQueue) + ; + + $config = new Config( + 'aPrefix', + '.', + '', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueue', + 'aRouterProcessor', + [], + [] + ); + + $driver = $this->createDriver($context, $config, new RouteCollection([])); + + $queue = $driver->createQueue('aFooQueue'); + + $this->assertSame($expectedQueue, $queue); + } + + public function testShouldCreateAndReturnQueueInstanceWithAppNameAndWithoutPrefix() + { + $expectedQueue = $this->createQueue('aName'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getAppFooQueueTransportName()) + ->willReturn($expectedQueue) + ; + + $config = new Config( + '', + '.', + 'anAppName', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueue', + 'aRouterProcessor', + [], + [] + ); + + $driver = $this->createDriver($context, $config, new RouteCollection([])); + + $queue = $driver->createQueue('aFooQueue'); + + $this->assertSame($expectedQueue, $queue); + } + + public function testShouldCreateAndReturnQueueInstanceWithoutPrefixAndAppName() + { + $expectedQueue = $this->createQueue('aName'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with('afooqueue') + ->willReturn($expectedQueue) + ; + + $config = new Config( + '', + '.', + '', + 'aRouterTopicName', + 'aRouterQueueName', + 'aDefaultQueue', + 'aRouterProcessor', + [], + [] + ); + + $driver = $this->createDriver($context, $config, new RouteCollection([])); + + $queue = $driver->createQueue('aFooQueue'); + + $this->assertSame($expectedQueue, $queue); + } + + public function testShouldCreateAndReturnQueueInstance() + { + $expectedQueue = $this->createQueue('aName'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getPrefixFooQueueTransportName()) + ->willReturn($expectedQueue) + ; + + $driver = $this->createDriver($context, $this->createDummyConfig(), new RouteCollection([])); + + $queue = $driver->createQueue('aFooQueue'); + + $this->assertSame($expectedQueue, $queue); + } + + public function testShouldCreateClientMessageFromTransportOne() + { + $transportMessage = $this->createMessage(); + $transportMessage->setBody('body'); + $transportMessage->setHeaders(['hkey' => 'hval']); + $transportMessage->setProperty('pkey', 'pval'); + $transportMessage->setProperty(Config::CONTENT_TYPE, 'theContentType'); + $transportMessage->setProperty(Config::EXPIRE, '22'); + $transportMessage->setProperty(Config::PRIORITY, MessagePriority::HIGH); + $transportMessage->setProperty('enqueue.delay', '44'); + $transportMessage->setMessageId('theMessageId'); + $transportMessage->setTimestamp(1000); + $transportMessage->setReplyTo('theReplyTo'); + $transportMessage->setCorrelationId('theCorrelationId'); + + $driver = $this->createDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $clientMessage = $driver->createClientMessage($transportMessage); + + $this->assertClientMessage($clientMessage); + } + + public function testShouldCreateTransportMessageFromClientOne() + { + $clientMessage = new Message(); + $clientMessage->setBody('body'); + $clientMessage->setHeaders(['hkey' => 'hval']); + $clientMessage->setProperties(['pkey' => 'pval']); + $clientMessage->setContentType('ContentType'); + $clientMessage->setExpire(123); + $clientMessage->setDelay(345); + $clientMessage->setPriority(MessagePriority::HIGH); + $clientMessage->setMessageId('theMessageId'); + $clientMessage->setTimestamp(1000); + $clientMessage->setReplyTo('theReplyTo'); + $clientMessage->setCorrelationId('theCorrelationId'); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($this->createMessage()) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $transportMessage = $driver->createTransportMessage($clientMessage); + + $this->assertTransportMessage($transportMessage); + } + + public function testShouldSendMessageToRouter() + { + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->willReturnCallback(function (Destination $topic, InteropMessage $message) use ($transportMessage) { + $this->assertSame( + $this->getRouterTransportName(), + $topic instanceof InteropTopic ? $topic->getTopicName() : $topic->getQueueName()); + $this->assertSame($transportMessage, $message); + }) + ; + $context = $this->createContextStub(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + + $driver->sendToRouter($message); + } + + public function testShouldNotInitDeliveryDelayOnSendMessageToRouter() + { + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ; + $producer + ->expects($this->never()) + ->method('setDeliveryDelay') + ; + + $context = $this->createContextStub(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setDelay(456); + $message->setProperty(Config::TOPIC, 'topic'); + + $driver->sendToRouter($message); + } + + public function testShouldNotInitTimeToLiveOnSendMessageToRouter() + { + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ; + $producer + ->expects($this->never()) + ->method('setTimeToLive') + ; + + $context = $this->createContextStub(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setExpire(456); + $message->setProperty(Config::TOPIC, 'topic'); + + $driver->sendToRouter($message); + } + + public function testShouldNotInitPriorityOnSendMessageToRouter() + { + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ; + $producer + ->expects($this->never()) + ->method('setPriority') + ; + + $context = $this->createContextStub(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setPriority(MessagePriority::HIGH); + $message->setProperty(Config::TOPIC, 'topic'); + + $driver->sendToRouter($message); + } + + public function testThrowIfTopicIsNotSetOnSendToRouter() + { + $driver = $this->createDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Topic name parameter is required but is not set'); + + $driver->sendToRouter(new Message()); + } + + public function testThrowIfCommandSetOnSendToRouter() + { + $driver = $this->createDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'aCommand'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Command must not be send to router but go directly to its processor.'); + + $driver->sendToRouter($message); + } + + public function testShouldSendMessageToRouterProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $config = $this->createDummyConfig(); + + $driver = $this->createDriver( + $context, + $config, + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor', [ + 'queue' => 'custom', + ]), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, $config->getRouterProcessor()); + + $driver->sendToProcessor($message); + } + + public function testShouldSendTopicMessageToProcessorToDefaultQueue() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testShouldSendTopicMessageToProcessorToCustomQueue() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getCustomQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testShouldInitDeliveryDelayIfDelayPropertyOnSendToProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('setDeliveryDelay') + ->with(456000) + ; + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $message = new Message(); + $message->setDelay(456); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testShouldSetInitTimeToLiveIfExpirePropertyOnSendToProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('setTimeToLive') + ->with(678000) + ; + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $message = new Message(); + $message->setExpire(678); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testShouldSetInitPriorityIfPriorityPropertyOnSendToProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('setPriority') + ->with(3) + ; + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $message = new Message(); + $message->setPriority(MessagePriority::HIGH); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testThrowIfNoRouteFoundForTopicMessageOnSendToProcessor() + { + $context = $this->createContextMock(); + $context + ->expects($this->never()) + ->method('createProducer') + ; + $context + ->expects($this->never()) + ->method('createMessage') + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no route for topic "topic" and processor "processor"'); + $driver->sendToProcessor($message); + } + + public function testShouldSetRouterProcessorIfProcessorPropertyEmptyOnSendToProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'expectedProcessor'), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + + $driver->sendToProcessor($message); + + $this->assertSame('router', $message->getProperty(Config::PROCESSOR)); + } + + public function testShouldSendCommandMessageToProcessorToDefaultQueue() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'processor'), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testShouldSendCommandMessageToProcessorToCustomQueue() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getCustomQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'processor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $driver->sendToProcessor($message); + } + + public function testThrowIfNoRouteFoundForCommandMessageOnSendToProcessor() + { + $context = $this->createContextMock(); + $context + ->expects($this->never()) + ->method('createProducer') + ; + $context + ->expects($this->never()) + ->method('createMessage') + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There is no route for command "command".'); + $driver->sendToProcessor($message); + } + + public function testShouldOverwriteProcessorPropertySetByOneFromCommandRouteOnSendToProcessor() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with($this->getCustomQueueTransportName()) + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'expectedProcessor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setProperty(Config::PROCESSOR, 'processorShouldBeOverwritten'); + + $driver->sendToProcessor($message); + + $this->assertSame('expectedProcessor', $message->getProperty(Config::PROCESSOR)); + } + + public function testShouldNotInitDeliveryDelayOnSendMessageToProcessorIfPropertyNull() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->never()) + ->method('setDeliveryDelay') + ; + $producer + ->expects($this->once()) + ->method('send') + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'expectedProcessor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setDelay(null); + + $driver->sendToProcessor($message); + } + + public function testShouldNotInitPriorityOnSendMessageToProcessorIfPropertyNull() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->never()) + ->method('setPriority') + ; + $producer + ->expects($this->once()) + ->method('send') + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'expectedProcessor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setPriority(null); + + $driver->sendToProcessor($message); + } + + public function testShouldNotInitTimeToLiveOnSendMessageToProcessorIfPropertyNull() + { + $queue = $this->createQueue(''); + $transportMessage = $this->createMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->never()) + ->method('setTimeToLive') + ; + $producer + ->expects($this->once()) + ->method('send') + ; + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('command', Route::COMMAND, 'expectedProcessor', ['queue' => 'custom']), + ]) + ); + + $message = new Message(); + $message->setProperty(Config::COMMAND, 'command'); + $message->setExpire(null); + + $driver->sendToProcessor($message); + } + + public function testThrowIfNeitherTopicNorCommandAreSentOnSendToProcessor() + { + $driver = $this->createDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Queue name parameter is required but is not set'); + + $message = new Message(); + $message->setProperty(Config::PROCESSOR, 'processor'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either topic or command parameter must be set.'); + $driver->sendToProcessor($message); + } + + abstract protected function createDriver(...$args): DriverInterface; + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + abstract protected function createContextMock(): Context; + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + abstract protected function createProducerMock(): InteropProducer; + + abstract protected function createQueue(string $name): InteropQueue; + + abstract protected function createTopic(string $name): InteropTopic; + + abstract protected function createMessage(): InteropMessage; + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + protected function createContextStub(): Context + { + $context = $this->createContextMock(); + + $context + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $name) { + return $this->createQueue($name); + }) + ; + + $context + ->expects($this->any()) + ->method('createTopic') + ->willReturnCallback(function (string $name) { + return $this->createTopic($name); + }) + ; + + return $context; + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + $this->assertEquals([ + 'hkey' => 'hval', + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply_to' => 'theReplyTo', + 'correlation_id' => 'theCorrelationId', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } + + protected function assertClientMessage(Message $clientMessage): void + { + $this->assertSame('body', $clientMessage->getBody()); + Assert::assertArraySubset([ + 'hkey' => 'hval', + ], $clientMessage->getHeaders()); + Assert::assertArraySubset([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'theContentType', + Config::EXPIRE => '22', + Config::PRIORITY => MessagePriority::HIGH, + Config::DELAY => '44', + ], $clientMessage->getProperties()); + $this->assertSame('theMessageId', $clientMessage->getMessageId()); + $this->assertSame(22, $clientMessage->getExpire()); + $this->assertSame(44, $clientMessage->getDelay()); + $this->assertSame(MessagePriority::HIGH, $clientMessage->getPriority()); + $this->assertSame('theContentType', $clientMessage->getContentType()); + $this->assertSame(1000, $clientMessage->getTimestamp()); + $this->assertSame('theReplyTo', $clientMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $clientMessage->getCorrelationId()); + } + + protected function createDummyConfig(): Config + { + return Config::create('aPrefix'); + } + + protected function getDefaultQueueTransportName(): string + { + return 'aprefix.default'; + } + + protected function getCustomQueueTransportName(): string + { + return 'aprefix.custom'; + } + + protected function getRouterTransportName(): string + { + return 'aprefix.default'; + } + + protected function getPrefixAppFooQueueTransportName(): string + { + return 'aprefix.anappname.afooqueue'; + } + + protected function getPrefixFooQueueTransportName(): string + { + return 'aprefix.afooqueue'; + } + + protected function getAppFooQueueTransportName(): string + { + return 'anappname.afooqueue'; + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/GpsDriverTest.php b/pkg/enqueue/Tests/Client/Driver/GpsDriverTest.php new file mode 100644 index 000000000..c0cac0458 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/GpsDriverTest.php @@ -0,0 +1,142 @@ +assertClassImplements(DriverInterface::class, GpsDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, GpsDriver::class); + } + + public function testShouldSetupBroker() + { + $routerTopic = new GpsTopic(''); + $routerQueue = new GpsQueue(''); + + $processorTopic = new GpsTopic($this->getDefaultQueueTransportName()); + $processorQueue = new GpsQueue($this->getDefaultQueueTransportName()); + + $context = $this->createContextMock(); + // setup router + $context + ->expects($this->at(0)) + ->method('createTopic') + ->willReturn($routerTopic) + ; + $context + ->expects($this->at(1)) + ->method('createQueue') + ->willReturn($routerQueue) + ; + $context + ->expects($this->at(2)) + ->method('subscribe') + ->with($this->identicalTo($routerTopic), $this->identicalTo($routerQueue)) + ; + $context + ->expects($this->at(3)) + ->method('createQueue') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($processorQueue) + ; + // setup processor queue + $context + ->expects($this->at(4)) + ->method('createTopic') + ->with($this->getDefaultQueueTransportName()) + ->willReturn($processorTopic) + ; + $context + ->expects($this->at(5)) + ->method('subscribe') + ->with($this->identicalTo($processorTopic), $this->identicalTo($processorQueue)) + ; + + $driver = new GpsDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('aTopic', Route::TOPIC, 'aProcessor'), + ]) + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new GpsDriver(...$args); + } + + /** + * @return GpsContext + */ + protected function createContextMock(): Context + { + return $this->createMock(GpsContext::class); + } + + /** + * @return GpsProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(GpsProducer::class); + } + + /** + * @return GpsQueue + */ + protected function createQueue(string $name): InteropQueue + { + return new GpsQueue($name); + } + + /** + * @return GpsTopic + */ + protected function createTopic(string $name): InteropTopic + { + return new GpsTopic($name); + } + + /** + * @return GpsMessage + */ + protected function createMessage(): InteropMessage + { + return new GpsMessage(); + } + + protected function getRouterTransportName(): string + { + return 'aprefix.router'; + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/MongodbDriverTest.php b/pkg/enqueue/Tests/Client/Driver/MongodbDriverTest.php new file mode 100644 index 000000000..697c757d8 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/MongodbDriverTest.php @@ -0,0 +1,105 @@ +assertClassImplements(DriverInterface::class, MongodbDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, MongodbDriver::class); + } + + public function testShouldSetupBroker() + { + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createCollection') + ; + $context + ->expects($this->once()) + ->method('getConfig') + ->willReturn([ + 'dbname' => 'aDb', + 'collection_name' => 'aCol', + ]) + ; + + $driver = new MongodbDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new MongodbDriver(...$args); + } + + /** + * @return MongodbContext + */ + protected function createContextMock(): Context + { + return $this->createMock(MongodbContext::class); + } + + /** + * @return MongodbProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(MongodbProducer::class); + } + + /** + * @return MongodbDestination + */ + protected function createQueue(string $name): InteropQueue + { + return new MongodbDestination($name); + } + + /** + * @return MongodbDestination + */ + protected function createTopic(string $name): InteropTopic + { + return new MongodbDestination($name); + } + + /** + * @return MongodbMessage + */ + protected function createMessage(): InteropMessage + { + return new MongodbMessage(); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/RabbitMqDriverTest.php b/pkg/enqueue/Tests/Client/Driver/RabbitMqDriverTest.php new file mode 100644 index 000000000..b209d85bc --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/RabbitMqDriverTest.php @@ -0,0 +1,139 @@ +assertClassImplements(DriverInterface::class, RabbitMqDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, RabbitMqDriver::class); + } + + public function testShouldBeSubClassOfAmqpDriver() + { + $this->assertClassExtends(AmqpDriver::class, RabbitMqDriver::class); + } + + public function testShouldCreateQueueWithMaxPriorityArgument() + { + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($this->createQueue('aName')) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var AmqpQueue $queue */ + $queue = $driver->createQueue('aName'); + + $this->assertSame(['x-max-priority' => 4], $queue->getArguments()); + } + + protected function createDriver(...$args): DriverInterface + { + return new RabbitMqDriver(...$args); + } + + /** + * @return AmqpContext + */ + protected function createContextMock(): Context + { + return $this->createMock(AmqpContext::class); + } + + /** + * @return AmqpProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(AmqpProducer::class); + } + + /** + * @return AmqpQueue + */ + protected function createQueue(string $name): InteropQueue + { + return new AmqpQueue($name); + } + + protected function createTopic(string $name): AmqpTopic + { + return new AmqpTopic($name); + } + + /** + * @return AmqpMessage + */ + protected function createMessage(): InteropMessage + { + return new AmqpMessage(); + } + + protected function getRouterTransportName(): string + { + return 'aprefix.router'; + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + Assert::assertArraySubset([ + 'hkey' => 'hval', + 'delivery_mode' => AmqpMessage::DELIVERY_MODE_PERSISTENT, + 'content_type' => 'ContentType', + 'expiration' => '123000', + 'priority' => 3, + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply_to' => 'theReplyTo', + 'correlation_id' => 'theCorrelationId', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/RabbitMqStompDriverTest.php b/pkg/enqueue/Tests/Client/Driver/RabbitMqStompDriverTest.php new file mode 100644 index 000000000..9fc72be1e --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/RabbitMqStompDriverTest.php @@ -0,0 +1,590 @@ +assertClassImplements(DriverInterface::class, RabbitMqStompDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, RabbitMqStompDriver::class); + } + + public function testShouldBeSubClassOfStompDriver() + { + $this->assertClassExtends(StompDriver::class, RabbitMqStompDriver::class); + } + + public function testShouldCreateAndReturnStompQueueInstance() + { + $expectedQueue = new StompDestination(ExtensionType::RABBITMQ); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->with('aprefix.afooqueue') + ->willReturn($expectedQueue) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]), + $this->createManagementClientMock() + ); + + $queue = $driver->createQueue('aFooQueue'); + + $expectedHeaders = [ + 'durable' => true, + 'auto-delete' => false, + 'exclusive' => false, + 'x-max-priority' => 4, + ]; + + $this->assertSame($expectedQueue, $queue); + $this->assertTrue($queue->isDurable()); + $this->assertFalse($queue->isAutoDelete()); + $this->assertFalse($queue->isExclusive()); + $this->assertSame($expectedHeaders, $queue->getHeaders()); + } + + public function testThrowIfClientPriorityInvalidOnCreateTransportMessage() + { + $clientMessage = new Message(); + $clientMessage->setPriority('unknown'); + + $transportMessage = new StompMessage(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]), + $this->createManagementClientMock() + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cant convert client priority to transport: "unknown"'); + + $driver->createTransportMessage($clientMessage); + } + + public function testThrowIfDelayIsSetButDelayPluginInstalledOptionIsFalse() + { + $clientMessage = new Message(); + $clientMessage->setDelay(123); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn(new StompMessage()) + ; + + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => false] + ); + + $driver = $this->createDriver( + $context, + $config, + new RouteCollection([]), + $this->createManagementClientMock() + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); + + $driver->createTransportMessage($clientMessage); + } + + public function testShouldSetXDelayHeaderIfDelayPluginInstalledOptionIsTrue() + { + $clientMessage = new Message(); + $clientMessage->setDelay(123); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn(new StompMessage()) + ; + + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => true] + ); + + $driver = $this->createDriver( + $context, + $config, + new RouteCollection([]), + $this->createManagementClientMock() + ); + + $transportMessage = $driver->createTransportMessage($clientMessage); + + $this->assertSame('123000', $transportMessage->getHeader('x-delay')); + } + + public function testShouldInitDeliveryDelayIfDelayPropertyOnSendToProcessor() + { + $this->shouldSendMessageToDelayExchangeIfDelaySet(); + } + + public function shouldSendMessageToDelayExchangeIfDelaySet() + { + $queue = new StompDestination(ExtensionType::RABBITMQ); + $queue->setStompName('queueName'); + + $delayTopic = new StompDestination(ExtensionType::RABBITMQ); + $delayTopic->setStompName('delayTopic'); + + $transportMessage = new StompMessage(); + + $producer = $this->createProducerMock(); + $producer + ->expects($this->at(0)) + ->method('setDeliveryDelay') + ->with(10000) + ; + $producer + ->expects($this->at(1)) + ->method('setDeliveryDelay') + ->with(null) + ; + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($delayTopic), $this->identicalTo($transportMessage)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($queue) + ; + $context + ->expects($this->once()) + ->method('createTopic') + ->willReturn($delayTopic) + ; + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($transportMessage) + ; + + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => true] + ); + + $driver = $this->createDriver( + $context, + $config, + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]), + $this->createManagementClientMock() + ); + + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topic'); + $message->setProperty(Config::PROCESSOR, 'processor'); + $message->setDelay(10); + + $driver->sendToProcessor($message); + } + + public function testShouldNotSetupBrokerIfManagementPluginInstalledOptionIsNotEnabled() + { + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['management_plugin_installed' => false] + ); + + $driver = $this->createDriver( + $this->createContextMock(), + $config, + new RouteCollection([]), + $this->createManagementClientMock() + ); + + $logger = new TestLogger(); + + $driver->setupBroker($logger); + + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Could not setup broker. The option `management_plugin_installed` is not enabled. Please enable that option and install rabbit management plugin' + ) + ); + } + + public function testShouldSetupBroker() + { + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]); + + $managementClient = $this->createManagementClientMock(); + $managementClient + ->expects($this->at(0)) + ->method('declareExchange') + ->with('aprefix.router', [ + 'type' => 'fanout', + 'durable' => true, + 'auto_delete' => false, + ]) + ; + $managementClient + ->expects($this->at(1)) + ->method('declareQueue') + ->with('aprefix.default', [ + 'durable' => true, + 'auto_delete' => false, + 'arguments' => [ + 'x-max-priority' => 4, + ], + ]) + ; + $managementClient + ->expects($this->at(2)) + ->method('bind') + ->with('aprefix.router', 'aprefix.default', 'aprefix.default') + ; + $managementClient + ->expects($this->at(3)) + ->method('declareQueue') + ->with('aprefix.default', [ + 'durable' => true, + 'auto_delete' => false, + 'arguments' => [ + 'x-max-priority' => 4, + ], + ]) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $name) { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_QUEUE); + $destination->setStompName($name); + + return $destination; + }) + ; + + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => false, 'management_plugin_installed' => true] + ); + + $driver = $this->createDriver( + $contextMock, + $config, + $routeCollection, + $managementClient + ); + + $logger = new TestLogger(); + + $driver->setupBroker($logger); + + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Declare router exchange: aprefix.router' + ) + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Declare router queue: aprefix.default' + ) + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Bind router queue to exchange: aprefix.default -> aprefix.router' + ) + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Declare processor queue: aprefix.default' + ) + ); + } + + public function testSetupBrokerShouldCreateDelayExchangeIfEnabled() + { + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]); + + $managementClient = $this->createManagementClientMock(); + $managementClient + ->expects($this->at(4)) + ->method('declareExchange') + ->with('aprefix.default.delayed', [ + 'type' => 'x-delayed-message', + 'durable' => true, + 'auto_delete' => false, + 'arguments' => [ + 'x-delayed-type' => 'direct', + ], + ]) + ; + $managementClient + ->expects($this->at(5)) + ->method('bind') + ->with('aprefix.default.delayed', 'aprefix.default', 'aprefix.default') + ; + + $config = Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => true, 'management_plugin_installed' => true] + ); + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $name) { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_QUEUE); + $destination->setStompName($name); + + return $destination; + }) + ; + $contextMock + ->expects($this->any()) + ->method('createTopic') + ->willReturnCallback(function (string $name) { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_TOPIC); + $destination->setStompName($name); + + return $destination; + }) + ; + + $driver = $this->createDriver( + $contextMock, + $config, + $routeCollection, + $managementClient + ); + + $logger = new TestLogger(); + + $driver->setupBroker($logger); + + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Declare delay exchange: aprefix.default.delayed' + ) + ); + self::assertTrue( + $logger->hasDebugThatContains( + '[RabbitMqStompDriver] Bind processor queue to delay exchange: aprefix.default -> aprefix.default.delayed' + ) + ); + } + + protected function createDriver(...$args): DriverInterface + { + return new RabbitMqStompDriver( + $args[0], + $args[1], + $args[2], + isset($args[3]) ? $args[3] : $this->createManagementClientMock() + ); + } + + /** + * @return StompContext + */ + protected function createContextMock(): Context + { + return $this->createMock(StompContext::class); + } + + /** + * @return StompProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(StompProducer::class); + } + + /** + * @return StompDestination + */ + protected function createQueue(string $name): InteropQueue + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_QUEUE); + $destination->setStompName($name); + + return $destination; + } + + /** + * @return StompDestination + */ + protected function createTopic(string $name): InteropTopic + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_TOPIC); + $destination->setStompName($name); + + return $destination; + } + + /** + * @return StompMessage + */ + protected function createMessage(): InteropMessage + { + return new StompMessage(); + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + $this->assertEquals([ + 'hkey' => 'hval', + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply-to' => 'theReplyTo', + 'persistent' => true, + 'correlation_id' => 'theCorrelationId', + 'expiration' => '123000', + 'priority' => 3, + 'x-delay' => '345000', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } + + protected function createDummyConfig(): Config + { + return Config::create( + 'aPrefix', + '.', + '', + null, + null, + null, + null, + ['delay_plugin_installed' => true, 'management_plugin_installed' => true] + ); + } + + protected function getRouterTransportName(): string + { + return '/topic/aprefix.router'; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createManagementClientMock(): StompManagementClient + { + return $this->createMock(StompManagementClient::class); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/RdKafkaDriverTest.php b/pkg/enqueue/Tests/Client/Driver/RdKafkaDriverTest.php new file mode 100644 index 000000000..c5e40e71d --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/RdKafkaDriverTest.php @@ -0,0 +1,122 @@ +assertClassImplements(DriverInterface::class, RdKafkaDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, RdKafkaDriver::class); + } + + public function testShouldSetupBroker() + { + $routerTopic = new RdKafkaTopic(''); + $routerQueue = new RdKafkaTopic(''); + + $processorTopic = new RdKafkaTopic(''); + + $context = $this->createContextMock(); + + $context + ->expects($this->at(0)) + ->method('createQueue') + ->willReturn($routerTopic) + ; + $context + ->expects($this->at(1)) + ->method('createQueue') + ->willReturn($routerQueue) + ; + $context + ->expects($this->at(2)) + ->method('createQueue') + ->willReturn($processorTopic) + ; + + $driver = new RdKafkaDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new RdKafkaDriver(...$args); + } + + /** + * @return RdKafkaContext + */ + protected function createContextMock(): Context + { + return $this->createMock(RdKafkaContext::class); + } + + /** + * @return RdKafkaProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(RdKafkaProducer::class); + } + + /** + * @return RdKafkaTopic + */ + protected function createQueue(string $name): InteropQueue + { + return new RdKafkaTopic($name); + } + + protected function createTopic(string $name): RdKafkaTopic + { + return new RdKafkaTopic($name); + } + + /** + * @return RdKafkaMessage + */ + protected function createMessage(): InteropMessage + { + return new RdKafkaMessage(); + } + + /** + * @return Config + */ + private function createDummyConfig() + { + return Config::create('aPrefix'); + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/SqsDriverTest.php b/pkg/enqueue/Tests/Client/Driver/SqsDriverTest.php new file mode 100644 index 000000000..2e3005e6a --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/SqsDriverTest.php @@ -0,0 +1,153 @@ +assertClassImplements(DriverInterface::class, SqsDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, SqsDriver::class); + } + + public function testShouldSetupBroker() + { + $routerQueue = new SqsDestination(''); + $processorQueue = new SqsDestination(''); + + $context = $this->createContextMock(); + // setup router + $context + ->expects($this->at(0)) + ->method('createQueue') + ->with('aprefix_dot_default') + ->willReturn($routerQueue) + ; + $context + ->expects($this->at(1)) + ->method('declareQueue') + ->with($this->identicalTo($routerQueue)) + ; + // setup processor queue + $context + ->expects($this->at(2)) + ->method('createQueue') + ->with('aprefix_dot_default') + ->willReturn($processorQueue) + ; + $context + ->expects($this->at(3)) + ->method('declareQueue') + ->with($this->identicalTo($processorQueue)) + ; + + $driver = new SqsDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]) + ); + + $driver->setupBroker(); + } + + protected function createDriver(...$args): DriverInterface + { + return new SqsDriver(...$args); + } + + /** + * @return SqsContext + */ + protected function createContextMock(): Context + { + return $this->createMock(SqsContext::class); + } + + /** + * @return SqsProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(SqsProducer::class); + } + + /** + * @return SqsDestination + */ + protected function createQueue(string $name): InteropQueue + { + return new SqsDestination($name); + } + + /** + * @return SqsDestination + */ + protected function createTopic(string $name): InteropTopic + { + return new SqsDestination($name); + } + + /** + * @return SqsMessage + */ + protected function createMessage(): InteropMessage + { + return new SqsMessage(); + } + + protected function getPrefixAppFooQueueTransportName(): string + { + return 'aprefix_dot_anappname_dot_afooqueue'; + } + + protected function getPrefixFooQueueTransportName(): string + { + return 'aprefix_dot_afooqueue'; + } + + protected function getAppFooQueueTransportName(): string + { + return 'anappname_dot_afooqueue'; + } + + protected function getDefaultQueueTransportName(): string + { + return 'aprefix_dot_default'; + } + + protected function getCustomQueueTransportName(): string + { + return 'aprefix_dot_custom'; + } + + protected function getRouterTransportName(): string + { + return 'aprefix_dot_default'; + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/StompDriverTest.php b/pkg/enqueue/Tests/Client/Driver/StompDriverTest.php new file mode 100644 index 000000000..8f777fdbd --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/StompDriverTest.php @@ -0,0 +1,191 @@ +assertClassImplements(DriverInterface::class, StompDriver::class); + } + + public function testShouldBeSubClassOfGenericDriver() + { + $this->assertClassExtends(GenericDriver::class, StompDriver::class); + } + + public function testSetupBrokerShouldOnlyLogMessageThatStompDoesNotSupportBrokerSetup() + { + $driver = new StompDriver( + $this->createContextMock(), + $this->createDummyConfig(), + new RouteCollection([]) + ); + + $logger = $this->createLoggerMock(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('[StompDriver] Stomp protocol does not support broker configuration') + ; + + $driver->setupBroker($logger); + } + + public function testShouldCreateDurableQueue() + { + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createQueue') + ->willReturn($this->createQueue('aName')) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var StompDestination $queue */ + $queue = $driver->createQueue('aName'); + + $this->assertTrue($queue->isDurable()); + $this->assertFalse($queue->isAutoDelete()); + $this->assertFalse($queue->isExclusive()); + } + + public function testShouldSetPersistedTrueOnCreateTransportMessage() + { + $clientMessage = new Message(); + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn($this->createMessage()) + ; + + $driver = $this->createDriver( + $context, + $this->createDummyConfig(), + new RouteCollection([]) + ); + + /** @var StompMessage $transportMessage */ + $transportMessage = $driver->createTransportMessage($clientMessage); + + $this->assertTrue($transportMessage->isPersistent()); + } + + protected function createDriver(...$args): DriverInterface + { + return new StompDriver(...$args); + } + + /** + * @return StompContext + */ + protected function createContextMock(): Context + { + return $this->createMock(StompContext::class); + } + + /** + * @return StompProducer + */ + protected function createProducerMock(): InteropProducer + { + return $this->createMock(StompProducer::class); + } + + /** + * @return StompDestination + */ + protected function createQueue(string $name): InteropQueue + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_QUEUE); + $destination->setStompName($name); + + return $destination; + } + + /** + * @return StompDestination + */ + protected function createTopic(string $name): InteropTopic + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setType(StompDestination::TYPE_TOPIC); + $destination->setStompName($name); + + return $destination; + } + + /** + * @return StompMessage + */ + protected function createMessage(): InteropMessage + { + return new StompMessage(); + } + + protected function assertTransportMessage(InteropMessage $transportMessage): void + { + $this->assertSame('body', $transportMessage->getBody()); + $this->assertEquals([ + 'hkey' => 'hval', + 'message_id' => 'theMessageId', + 'timestamp' => 1000, + 'reply-to' => 'theReplyTo', + 'persistent' => true, + 'correlation_id' => 'theCorrelationId', + ], $transportMessage->getHeaders()); + $this->assertEquals([ + 'pkey' => 'pval', + Config::CONTENT_TYPE => 'ContentType', + Config::PRIORITY => MessagePriority::HIGH, + Config::EXPIRE => 123, + Config::DELAY => 345, + ], $transportMessage->getProperties()); + $this->assertSame('theMessageId', $transportMessage->getMessageId()); + $this->assertSame(1000, $transportMessage->getTimestamp()); + $this->assertSame('theReplyTo', $transportMessage->getReplyTo()); + $this->assertSame('theCorrelationId', $transportMessage->getCorrelationId()); + } + + protected function createLoggerMock(): LoggerInterface + { + return $this->createMock(LoggerInterface::class); + } + + protected function getRouterTransportName(): string + { + return '/topic/aprefix.router'; + } +} diff --git a/pkg/enqueue/Tests/Client/Driver/StompManagementClientTest.php b/pkg/enqueue/Tests/Client/Driver/StompManagementClientTest.php new file mode 100644 index 000000000..081a62c5f --- /dev/null +++ b/pkg/enqueue/Tests/Client/Driver/StompManagementClientTest.php @@ -0,0 +1,114 @@ +createExchangeMock(); + $exchange + ->expects($this->once()) + ->method('create') + ->with('vhost', 'name', ['options']) + ->willReturn([]) + ; + + $client = $this->createClientMock(); + $client + ->expects($this->once()) + ->method('exchanges') + ->willReturn($exchange) + ; + + $management = new StompManagementClient($client, 'vhost'); + $management->declareExchange('name', ['options']); + } + + public function testCouldDeclareQueue() + { + $queue = $this->createQueueMock(); + $queue + ->expects($this->once()) + ->method('create') + ->with('vhost', 'name', ['options']) + ->willReturn([]) + ; + + $client = $this->createClientMock(); + $client + ->expects($this->once()) + ->method('queues') + ->willReturn($queue) + ; + + $management = new StompManagementClient($client, 'vhost'); + $management->declareQueue('name', ['options']); + } + + public function testCouldBind() + { + $binding = $this->createBindingMock(); + $binding + ->expects($this->once()) + ->method('create') + ->with('vhost', 'exchange', 'queue', 'routing-key', ['arguments']) + ->willReturn([]) + ; + + $client = $this->createClientMock(); + $client + ->expects($this->once()) + ->method('bindings') + ->willReturn($binding) + ; + + $management = new StompManagementClient($client, 'vhost'); + $management->bind('exchange', 'queue', 'routing-key', ['arguments']); + } + + public function testCouldCreateNewInstanceUsingFactory() + { + $instance = StompManagementClient::create('', ''); + + $this->assertInstanceOf(StompManagementClient::class, $instance); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Client + */ + private function createClientMock() + { + return $this->createMock(Client::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Exchange + */ + private function createExchangeMock() + { + return $this->createMock(Exchange::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Queue + */ + private function createQueueMock() + { + return $this->createMock(Queue::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Binding + */ + private function createBindingMock() + { + return $this->createMock(Binding::class); + } +} diff --git a/pkg/enqueue/Tests/Client/DriverFactoryTest.php b/pkg/enqueue/Tests/Client/DriverFactoryTest.php new file mode 100644 index 000000000..3d9d7b9b5 --- /dev/null +++ b/pkg/enqueue/Tests/Client/DriverFactoryTest.php @@ -0,0 +1,186 @@ +assertTrue($rc->implementsInterface(DriverFactoryInterface::class)); + } + + public function testShouldBeFinal() + { + $rc = new \ReflectionClass(DriverFactory::class); + + $this->assertTrue($rc->isFinal()); + } + + public function testThrowIfPackageThatSupportSchemeNotInstalled() + { + $scheme = 'scheme5b7aa7d7cd213'; + $class = 'ConnectionClass5b7aa7d7cd213'; + + Resources::addDriver($class, [$scheme], [], ['thePackage', 'theOtherPackage']); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('To use given scheme "scheme5b7aa7d7cd213" a package has to be installed. Run "composer req thePackage theOtherPackage" to add it.'); + $factory = new DriverFactory(); + + $factory->create($this->createConnectionFactoryMock(), $this->createDummyConfig($scheme.'://foo'), new RouteCollection([])); + } + + public function testThrowIfSchemeIsNotKnown() + { + $scheme = 'scheme5b7aa862e70a5'; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A given scheme "scheme5b7aa862e70a5" is not supported. Maybe it is a custom driver, make sure you registered it with "Enqueue\Client\Resources::addDriver".'); + + $factory = new DriverFactory(); + + $factory->create($this->createConnectionFactoryMock(), $this->createDummyConfig($scheme.'://foo'), new RouteCollection([])); + } + + public function testThrowIfDsnInvalid() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid. It does not have scheme separator ":".'); + + $factory = new DriverFactory(); + + $factory->create($this->createConnectionFactoryMock(), $this->createDummyConfig('invalidDsn'), new RouteCollection([])); + } + + /** + * @dataProvider provideDSN + */ + public function testReturnsExpectedFactories( + string $dsn, + string $connectionFactoryClass, + string $contextClass, + array $conifg, + string $expectedDriverClass, + ) { + $connectionFactoryMock = $this->createMock($connectionFactoryClass); + $connectionFactoryMock + ->expects($this->once()) + ->method('createContext') + ->willReturn($this->createMock($contextClass)) + ; + + $driverFactory = new DriverFactory(); + + $driver = $driverFactory->create($connectionFactoryMock, $this->createDummyConfig($dsn), new RouteCollection([])); + + $this->assertInstanceOf($expectedDriverClass, $driver); + } + + public static function provideDSN() + { + yield ['null:', NullConnectionFactory::class, NullContext::class, [], GenericDriver::class]; + + yield ['amqp:', AmqpConnectionFactory::class, AmqpContext::class, [], AmqpDriver::class]; + + yield ['amqp+rabbitmq:', AmqpConnectionFactory::class, AmqpContext::class, [], RabbitMqDriver::class]; + + yield ['mysql:', DbalConnectionFactory::class, DbalContext::class, [], DbalDriver::class]; + + yield ['file:', FsConnectionFactory::class, FsContext::class, [], FsDriver::class]; + + // https://github.com/php-enqueue/enqueue-dev/issues/511 + // yield ['gearman:', GearmanConnectionFactory::class, NullContext::class, [], NullDriver::class]; + + yield ['gps:', GpsConnectionFactory::class, GpsContext::class, [], GpsDriver::class]; + + yield ['mongodb:', MongodbConnectionFactory::class, MongodbContext::class, [], MongodbDriver::class]; + + yield ['kafka:', RdKafkaConnectionFactory::class, RdKafkaContext::class, [], RdKafkaDriver::class]; + + yield ['redis:', RedisConnectionFactory::class, RedisContext::class, [], GenericDriver::class]; + + yield ['redis+predis:', RedisConnectionFactory::class, RedisContext::class, [], GenericDriver::class]; + + yield ['sqs:', SqsConnectionFactory::class, SqsContext::class, [], SqsDriver::class]; + + yield ['stomp:', StompConnectionFactory::class, StompContext::class, [], StompDriver::class]; + + yield ['stomp+rabbitmq:', StompConnectionFactory::class, StompContext::class, [], RabbitMqStompDriver::class]; + + yield ['stomp+foo+bar:', StompConnectionFactory::class, StompContext::class, [], StompDriver::class]; + + yield ['gearman:', GearmanConnectionFactory::class, GearmanContext::class, [], GenericDriver::class]; + + yield ['beanstalk:', PheanstalkConnectionFactory::class, PheanstalkContext::class, [], GenericDriver::class]; + } + + private function createDummyConfig(string $dsn): Config + { + return Config::create( + null, + null, + null, + null, + null, + null, + null, + ['dsn' => $dsn], + [] + ); + } + + private function createConnectionFactoryMock(): ConnectionFactory + { + return $this->createMock(ConnectionFactory::class); + } + + private function createConfigMock(): Config + { + return $this->createMock(Config::class); + } +} diff --git a/pkg/enqueue/Tests/Client/DriverPreSendTest.php b/pkg/enqueue/Tests/Client/DriverPreSendTest.php new file mode 100644 index 000000000..32af2a81f --- /dev/null +++ b/pkg/enqueue/Tests/Client/DriverPreSendTest.php @@ -0,0 +1,84 @@ +createProducerMock(); + $expectedDriver = $this->createDriverMock(); + + $context = new DriverPreSend( + $expectedMessage, + $expectedProducer, + $expectedDriver + ); + + $this->assertSame($expectedMessage, $context->getMessage()); + $this->assertSame($expectedProducer, $context->getProducer()); + $this->assertSame($expectedDriver, $context->getDriver()); + } + + public function testShouldAllowGetCommand() + { + $message = new Message(); + $message->setProperty(Config::COMMAND, 'theCommand'); + + $context = new DriverPreSend( + $message, + $this->createProducerMock(), + $this->createDriverMock() + ); + + $this->assertFalse($context->isEvent()); + $this->assertSame('theCommand', $context->getCommand()); + } + + public function testShouldAllowGetTopic() + { + $message = new Message(); + $message->setProperty(Config::TOPIC, 'theTopic'); + + $context = new DriverPreSend( + $message, + $this->createProducerMock(), + $this->createDriverMock() + ); + + $this->assertTrue($context->isEvent()); + $this->assertSame('theTopic', $context->getTopic()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverMock(): DriverInterface + { + return $this->createMock(DriverInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createProducerMock(): ProducerInterface + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/Extension/PrepareBodyExtensionTest.php b/pkg/enqueue/Tests/Client/Extension/PrepareBodyExtensionTest.php new file mode 100644 index 000000000..c3032ccc8 --- /dev/null +++ b/pkg/enqueue/Tests/Client/Extension/PrepareBodyExtensionTest.php @@ -0,0 +1,131 @@ +assertTrue($rc->implementsInterface(PreSendEventExtensionInterface::class)); + $this->assertTrue($rc->implementsInterface(PreSendCommandExtensionInterface::class)); + } + + /** + * @dataProvider provideMessages + * + * @param mixed|null $contentType + */ + public function testShouldSendStringUnchangedAndAddPlainTextContentTypeIfEmpty( + $body, + $contentType, + string $expectedBody, + string $expectedContentType, + ) { + $message = new Message($body); + $message->setContentType($contentType); + + $context = $this->createDummyPreSendContext('aTopic', $message); + + $extension = new PrepareBodyExtension(); + + $extension->onPreSendEvent($context); + + $this->assertSame($expectedBody, $message->getBody()); + $this->assertSame($expectedContentType, $message->getContentType()); + } + + public function testThrowIfBodyIsObject() + { + $message = new Message(new \stdClass()); + + $context = $this->createDummyPreSendContext('aTopic', $message); + + $extension = new PrepareBodyExtension(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The message\'s body must be either null, scalar, array or object (implements \JsonSerializable). Got: stdClass'); + + $extension->onPreSendEvent($context); + } + + public function testThrowIfBodyIsArrayWithObjectsInsideOnSend() + { + $message = new Message(['foo' => new \stdClass()]); + + $context = $this->createDummyPreSendContext('aTopic', $message); + + $extension = new PrepareBodyExtension(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message\'s body must be an array of scalars. Found not scalar in the array: stdClass'); + + $extension->onPreSendEvent($context); + } + + public function testShouldThrowExceptionIfBodyIsArrayWithObjectsInSubArraysInsideOnSend() + { + $message = new Message(['foo' => ['bar' => new \stdClass()]]); + + $context = $this->createDummyPreSendContext('aTopic', $message); + + $extension = new PrepareBodyExtension(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message\'s body must be an array of scalars. Found not scalar in the array: stdClass'); + + $extension->onPreSendEvent($context); + } + + public static function provideMessages() + { + yield ['theBody', null, 'theBody', 'text/plain']; + + yield ['theBody', 'foo/bar', 'theBody', 'foo/bar']; + + yield [12345, null, '12345', 'text/plain']; + + yield [12345, 'foo/bar', '12345', 'foo/bar']; + + yield [12.345, null, '12.345', 'text/plain']; + + yield [12.345, 'foo/bar', '12.345', 'foo/bar']; + + yield [true, null, '1', 'text/plain']; + + yield [true, 'foo/bar', '1', 'foo/bar']; + + yield [null, null, '', 'text/plain']; + + yield [null, 'foo/bar', '', 'foo/bar']; + + yield [['foo' => 'fooVal'], null, '{"foo":"fooVal"}', 'application/json']; + + yield [['foo' => 'fooVal'], 'foo/bar', '{"foo":"fooVal"}', 'foo/bar']; + + yield [new JsonSerializableObject(), null, '{"foo":"fooVal"}', 'application/json']; + + yield [new JsonSerializableObject(), 'foo/bar', '{"foo":"fooVal"}', 'foo/bar']; + } + + private function createDummyPreSendContext($commandOrTopic, $message): PreSend + { + return new PreSend( + $commandOrTopic, + $message, + $this->createMock(ProducerInterface::class), + $this->createMock(DriverInterface::class) + ); + } +} diff --git a/pkg/enqueue/Tests/Client/MessagePriorityTest.php b/pkg/enqueue/Tests/Client/MessagePriorityTest.php index 5778e0299..a677884d2 100644 --- a/pkg/enqueue/Tests/Client/MessagePriorityTest.php +++ b/pkg/enqueue/Tests/Client/MessagePriorityTest.php @@ -3,8 +3,9 @@ namespace Enqueue\Tests\Client; use Enqueue\Client\MessagePriority; +use PHPUnit\Framework\TestCase; -class MessagePriorityTest extends \PHPUnit_Framework_TestCase +class MessagePriorityTest extends TestCase { public function testShouldVeryLowPriorityHasExpectedValue() { diff --git a/pkg/enqueue/Tests/Client/MessageProducerTest.php b/pkg/enqueue/Tests/Client/MessageProducerTest.php deleted file mode 100644 index b85aa1b98..000000000 --- a/pkg/enqueue/Tests/Client/MessageProducerTest.php +++ /dev/null @@ -1,349 +0,0 @@ -createDriverStub()); - } - - public function testShouldSendMessageToRouter() - { - $message = new Message(); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - $expectedProperties = [ - 'enqueue.topic_name' => 'topic', - ]; - - self::assertEquals($expectedProperties, $message->getProperties()); - } - - public function testShouldSendMessageWithNormalPriorityByDefault() - { - $message = new Message(); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertSame(MessagePriority::NORMAL, $message->getPriority()); - } - - public function testShouldSendMessageWithCustomPriority() - { - $message = new Message(); - $message->setPriority(MessagePriority::HIGH); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertSame(MessagePriority::HIGH, $message->getPriority()); - } - - public function testShouldSendMessageWithGeneratedMessageId() - { - $message = new Message(); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertNotEmpty($message->getMessageId()); - } - - public function testShouldSendMessageWithCustomMessageId() - { - $message = new Message(); - $message->setMessageId('theCustomMessageId'); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertSame('theCustomMessageId', $message->getMessageId()); - } - - public function testShouldSendMessageWithGeneratedTimestamp() - { - $message = new Message(); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertNotEmpty($message->getTimestamp()); - } - - public function testShouldSendMessageWithCustomTimestamp() - { - $message = new Message(); - $message->setTimestamp('theCustomTimestamp'); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->with(self::identicalTo($message)) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - - self::assertSame('theCustomTimestamp', $message->getTimestamp()); - } - - public function testShouldSendStringAsPlainText() - { - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertSame('theStringMessage', $message->getBody()); - self::assertSame('text/plain', $message->getContentType()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', 'theStringMessage'); - } - - public function testShouldSendArrayAsJsonString() - { - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertSame('{"foo":"fooVal"}', $message->getBody()); - self::assertSame('application/json', $message->getContentType()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', ['foo' => 'fooVal']); - } - - public function testShouldConvertMessageArrayBodyJsonString() - { - $message = new Message(); - $message->setBody(['foo' => 'fooVal']); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertSame('{"foo":"fooVal"}', $message->getBody()); - self::assertSame('application/json', $message->getContentType()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send('topic', $message); - } - - public function testSendShouldForceScalarsToStringAndSetTextContentType() - { - $queue = new NullQueue(''); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertEquals('text/plain', $message->getContentType()); - - self::assertInternalType('string', $message->getBody()); - self::assertEquals('12345', $message->getBody()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send($queue, 12345); - } - - public function testSendShouldForceMessageScalarsBodyToStringAndSetTextContentType() - { - $queue = new NullQueue(''); - - $message = new Message(); - $message->setBody(12345); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertEquals('text/plain', $message->getContentType()); - - self::assertInternalType('string', $message->getBody()); - self::assertEquals('12345', $message->getBody()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send($queue, $message); - } - - public function testSendShouldForceNullToEmptyStringAndSetTextContentType() - { - $queue = new NullQueue(''); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertEquals('text/plain', $message->getContentType()); - - self::assertInternalType('string', $message->getBody()); - self::assertEquals('', $message->getBody()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send($queue, null); - } - - public function testSendShouldForceNullBodyToEmptyStringAndSetTextContentType() - { - $queue = new NullQueue(''); - - $message = new Message(); - $message->setBody(null); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->once()) - ->method('sendToRouter') - ->willReturnCallback(function (Message $message) { - self::assertEquals('text/plain', $message->getContentType()); - - self::assertInternalType('string', $message->getBody()); - self::assertEquals('', $message->getBody()); - }) - ; - - $producer = new MessageProducer($driver); - $producer->send($queue, $message); - } - - public function testShouldThrowExceptionIfBodyIsObjectOnSend() - { - $driver = $this->createDriverStub(); - $driver - ->expects($this->never()) - ->method('sendToRouter') - ; - - $producer = new MessageProducer($driver); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The message\'s body must be either null, scalar or array. Got: stdClass'); - - $producer->send('topic', new \stdClass()); - } - - public function testShouldThrowExceptionIfBodyIsArrayWithObjectsInsideOnSend() - { - $queue = new NullQueue('queue'); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->never()) - ->method('sendToRouter') - ; - - $producer = new MessageProducer($driver); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The message\'s body must be an array of scalars. Found not scalar in the array: stdClass'); - - $producer->send($queue, ['foo' => new \stdClass()]); - } - - public function testShouldThrowExceptionIfBodyIsArrayWithObjectsInSubArraysInsideOnSend() - { - $queue = new NullQueue('queue'); - - $driver = $this->createDriverStub(); - $driver - ->expects($this->never()) - ->method('sendToRouter') - ; - - $producer = new MessageProducer($driver); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The message\'s body must be an array of scalars. Found not scalar in the array: stdClass'); - - $producer->send($queue, ['foo' => ['bar' => new \stdClass()]]); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface - */ - protected function createDriverStub() - { - return $this->createMock(DriverInterface::class); - } -} diff --git a/pkg/enqueue/Tests/Client/MessageTest.php b/pkg/enqueue/Tests/Client/MessageTest.php index 73425c786..a9a1d956c 100644 --- a/pkg/enqueue/Tests/Client/MessageTest.php +++ b/pkg/enqueue/Tests/Client/MessageTest.php @@ -3,12 +3,26 @@ namespace Enqueue\Tests\Client; use Enqueue\Client\Message; +use PHPUnit\Framework\TestCase; -class MessageTest extends \PHPUnit_Framework_TestCase +class MessageTest extends TestCase { - public function testCouldBeConstructedWithoutAnyArguments() + public function testCouldBeConstructedWithoutArguments() { - new Message(); + $message = new Message(); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new Message('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); } public function testShouldAllowGetPreviouslySetBody() @@ -38,6 +52,15 @@ public function testShouldAllowGetPreviouslySetDelay() self::assertSame('theDelay', $message->getDelay()); } + public function testShouldAllowGetPreviouslySetScope() + { + $message = new Message(); + + $message->setScope('theScope'); + + self::assertSame('theScope', $message->getScope()); + } + public function testShouldAllowGetPreviouslySetExpire() { $message = new Message(); @@ -81,6 +104,31 @@ public function testShouldSetEmptyArrayAsDefaultHeadersInConstructor() self::assertSame([], $message->getHeaders()); } + public function testShouldSetMessageBusScopeInConstructor() + { + $message = new Message(); + + self::assertSame(Message::SCOPE_MESSAGE_BUS, $message->getScope()); + } + + public function testShouldAllowGetPreviouslySetReplyTo() + { + $message = new Message(); + + $message->setReplyTo('theReplyTo'); + + self::assertSame('theReplyTo', $message->getReplyTo()); + } + + public function testShouldAllowGetPreviouslySetCorrelationId() + { + $message = new Message(); + + $message->setCorrelationId('theCorrelationId'); + + self::assertSame('theCorrelationId', $message->getCorrelationId()); + } + public function testShouldAllowGetPreviouslySetHeaders() { $message = new Message(); diff --git a/pkg/enqueue/Tests/Client/Meta/QueueMetaRegistryTest.php b/pkg/enqueue/Tests/Client/Meta/QueueMetaRegistryTest.php deleted file mode 100644 index 028ab941e..000000000 --- a/pkg/enqueue/Tests/Client/Meta/QueueMetaRegistryTest.php +++ /dev/null @@ -1,134 +0,0 @@ - [], - 'anotherQueueName' => [], - ]; - - $registry = new QueueMetaRegistry($this->createConfig(), $meta); - - $this->assertAttributeEquals($meta, 'meta', $registry); - } - - public function testShouldAllowAddQueueMetaUsingAddMethod() - { - $registry = new QueueMetaRegistry($this->createConfig(), []); - - $registry->add('theFooQueueName', 'theTransportQueueName'); - $registry->add('theBarQueueName'); - - $this->assertAttributeSame([ - 'theFooQueueName' => [ - 'transportName' => 'theTransportQueueName', - 'processors' => [], - ], - 'theBarQueueName' => [ - 'transportName' => null, - 'processors' => [], - ], - ], 'meta', $registry); - } - - public function testShouldAllowAddSubscriber() - { - $registry = new QueueMetaRegistry($this->createConfig(), []); - - $registry->addProcessor('theFooQueueName', 'theFooProcessorName'); - $registry->addProcessor('theFooQueueName', 'theBarProcessorName'); - $registry->addProcessor('theBarQueueName', 'theBazProcessorName'); - - $this->assertAttributeSame([ - 'theFooQueueName' => [ - 'transportName' => null, - 'processors' => ['theFooProcessorName', 'theBarProcessorName'], - ], - 'theBarQueueName' => [ - 'transportName' => null, - 'processors' => ['theBazProcessorName'], - ], - ], 'meta', $registry); - } - - public function testThrowIfThereIsNotMetaForRequestedClientQueueName() - { - $registry = new QueueMetaRegistry($this->createConfig(), []); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The queue meta not found. Requested name `aName`'); - $registry->getQueueMeta('aName'); - } - - public function testShouldAllowGetQueueByNameWithDefaultInfo() - { - $queues = [ - 'theQueueName' => [], - ]; - - $registry = new QueueMetaRegistry($this->createConfig(), $queues); - - $queue = $registry->getQueueMeta('theQueueName'); - - $this->assertInstanceOf(QueueMeta::class, $queue); - $this->assertSame('theQueueName', $queue->getClientName()); - $this->assertSame('aprefix.anappname.thequeuename', $queue->getTransportName()); - $this->assertSame([], $queue->getProcessors()); - } - - public function testShouldAllowGetQueueByNameWithCustomInfo() - { - $queues = [ - 'theClientQueueName' => ['transportName' => 'theTransportName', 'processors' => ['theSubscriber']], - ]; - - $registry = new QueueMetaRegistry($this->createConfig(), $queues); - - $queue = $registry->getQueueMeta('theClientQueueName'); - $this->assertInstanceOf(QueueMeta::class, $queue); - $this->assertSame('theClientQueueName', $queue->getClientName()); - $this->assertSame('theTransportName', $queue->getTransportName()); - $this->assertSame(['theSubscriber'], $queue->getProcessors()); - } - - public function testShouldAllowGetAllQueues() - { - $queues = [ - 'fooQueueName' => [], - 'barQueueName' => [], - ]; - - $registry = new QueueMetaRegistry($this->createConfig(), $queues); - - $queues = $registry->getQueuesMeta(); - $this->assertInstanceOf(\Generator::class, $queues); - - $queues = iterator_to_array($queues); - /* @var QueueMeta[] $queues */ - - $this->assertContainsOnly(QueueMeta::class, $queues); - $this->assertCount(2, $queues); - - $this->assertSame('fooQueueName', $queues[0]->getClientName()); - $this->assertSame('aprefix.anappname.fooqueuename', $queues[0]->getTransportName()); - - $this->assertSame('barQueueName', $queues[1]->getClientName()); - $this->assertSame('aprefix.anappname.barqueuename', $queues[1]->getTransportName()); - } - - /** - * @return Config - */ - private function createConfig() - { - return new Config('aPrefix', 'anAppName', 'aRouterTopic', 'aRouterQueueName', 'aDefaultQueueName', 'aRouterProcessorName'); - } -} diff --git a/pkg/enqueue/Tests/Client/Meta/QueueMetaTest.php b/pkg/enqueue/Tests/Client/Meta/QueueMetaTest.php deleted file mode 100644 index b184c06a6..000000000 --- a/pkg/enqueue/Tests/Client/Meta/QueueMetaTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertAttributeEquals('aClientName', 'clientName', $destination); - $this->assertAttributeEquals('aTransportName', 'transportName', $destination); - $this->assertAttributeEquals([], 'processors', $destination); - } - - public function testShouldAllowGetClientNameSetInConstructor() - { - $destination = new QueueMeta('theClientName', 'aTransportName'); - - $this->assertSame('theClientName', $destination->getClientName()); - } - - public function testShouldAllowGetTransportNameSetInConstructor() - { - $destination = new QueueMeta('aClientName', 'theTransportName'); - - $this->assertSame('theTransportName', $destination->getTransportName()); - } - - public function testShouldAllowGetSubscribersSetInConstructor() - { - $destination = new QueueMeta('aClientName', 'aTransportName', ['aSubscriber']); - - $this->assertSame(['aSubscriber'], $destination->getProcessors()); - } -} diff --git a/pkg/enqueue/Tests/Client/Meta/TopicMetaRegistryTest.php b/pkg/enqueue/Tests/Client/Meta/TopicMetaRegistryTest.php deleted file mode 100644 index 9f41e4dca..000000000 --- a/pkg/enqueue/Tests/Client/Meta/TopicMetaRegistryTest.php +++ /dev/null @@ -1,123 +0,0 @@ - [], - 'anotherTopicName' => [], - ]; - - $registry = new TopicMetaRegistry($topics); - - $this->assertAttributeEquals($topics, 'meta', $registry); - } - - public function testShouldAllowAddTopicMetaUsingAddMethod() - { - $registry = new TopicMetaRegistry([]); - - $registry->add('theFooTopicName', 'aDescription'); - $registry->add('theBarTopicName'); - - $this->assertAttributeSame([ - 'theFooTopicName' => [ - 'description' => 'aDescription', - 'processors' => [], - ], - 'theBarTopicName' => [ - 'description' => null, - 'processors' => [], - ], - ], 'meta', $registry); - } - - public function testShouldAllowAddSubscriber() - { - $registry = new TopicMetaRegistry([]); - - $registry->addProcessor('theFooTopicName', 'theFooProcessorName'); - $registry->addProcessor('theFooTopicName', 'theBarProcessorName'); - $registry->addProcessor('theBarTopicName', 'theBazProcessorName'); - - $this->assertAttributeSame([ - 'theFooTopicName' => [ - 'description' => null, - 'processors' => ['theFooProcessorName', 'theBarProcessorName'], - ], - 'theBarTopicName' => [ - 'description' => null, - 'processors' => ['theBazProcessorName'], - ], - ], 'meta', $registry); - } - - public function testThrowIfThereIsNotMetaForRequestedTopicName() - { - $registry = new TopicMetaRegistry([]); - - $this->setExpectedException( - \InvalidArgumentException::class, - 'The topic meta not found. Requested name `aName`' - ); - $registry->getTopicMeta('aName'); - } - - public function testShouldAllowGetTopicByNameWithDefaultInfo() - { - $topics = [ - 'theTopicName' => [], - ]; - - $registry = new TopicMetaRegistry($topics); - - $topic = $registry->getTopicMeta('theTopicName'); - $this->assertInstanceOf(TopicMeta::class, $topic); - $this->assertSame('theTopicName', $topic->getName()); - $this->assertSame('', $topic->getDescription()); - $this->assertSame([], $topic->getProcessors()); - } - - public function testShouldAllowGetTopicByNameWithCustomInfo() - { - $topics = [ - 'theTopicName' => ['description' => 'theDescription', 'processors' => ['theSubscriber']], - ]; - - $registry = new TopicMetaRegistry($topics); - - $topic = $registry->getTopicMeta('theTopicName'); - $this->assertInstanceOf(TopicMeta::class, $topic); - $this->assertSame('theTopicName', $topic->getName()); - $this->assertSame('theDescription', $topic->getDescription()); - $this->assertSame(['theSubscriber'], $topic->getProcessors()); - } - - public function testShouldAllowGetAllTopics() - { - $topics = [ - 'fooTopicName' => [], - 'barTopicName' => [], - ]; - - $registry = new TopicMetaRegistry($topics); - - $topics = $registry->getTopicsMeta(); - $this->assertInstanceOf(\Generator::class, $topics); - - $topics = iterator_to_array($topics); - /* @var TopicMeta[] $topics */ - - $this->assertContainsOnly(TopicMeta::class, $topics); - $this->assertCount(2, $topics); - - $this->assertSame('fooTopicName', $topics[0]->getName()); - $this->assertSame('barTopicName', $topics[1]->getName()); - } -} diff --git a/pkg/enqueue/Tests/Client/Meta/TopicMetaTest.php b/pkg/enqueue/Tests/Client/Meta/TopicMetaTest.php deleted file mode 100644 index ac5c2992d..000000000 --- a/pkg/enqueue/Tests/Client/Meta/TopicMetaTest.php +++ /dev/null @@ -1,56 +0,0 @@ -assertAttributeEquals('aName', 'name', $topic); - $this->assertAttributeEquals('', 'description', $topic); - $this->assertAttributeEquals([], 'processors', $topic); - } - - public function testCouldBeConstructedWithNameAndDescriptionOnly() - { - $topic = new TopicMeta('aName', 'aDescription'); - - $this->assertAttributeEquals('aName', 'name', $topic); - $this->assertAttributeEquals('aDescription', 'description', $topic); - $this->assertAttributeEquals([], 'processors', $topic); - } - - public function testCouldBeConstructedWithNameAndDescriptionAndSubscribers() - { - $topic = new TopicMeta('aName', 'aDescription', ['aSubscriber']); - - $this->assertAttributeEquals('aName', 'name', $topic); - $this->assertAttributeEquals('aDescription', 'description', $topic); - $this->assertAttributeEquals(['aSubscriber'], 'processors', $topic); - } - - public function testShouldAllowGetNameSetInConstructor() - { - $topic = new TopicMeta('theName', 'aDescription'); - - $this->assertSame('theName', $topic->getName()); - } - - public function testShouldAllowGetDescriptionSetInConstructor() - { - $topic = new TopicMeta('aName', 'theDescription'); - - $this->assertSame('theDescription', $topic->getDescription()); - } - - public function testShouldAllowGetSubscribersSetInConstructor() - { - $topic = new TopicMeta('aName', '', ['aSubscriber']); - - $this->assertSame(['aSubscriber'], $topic->getProcessors()); - } -} diff --git a/pkg/enqueue/Tests/Client/NullDriverTest.php b/pkg/enqueue/Tests/Client/NullDriverTest.php deleted file mode 100644 index 00aee3e9c..000000000 --- a/pkg/enqueue/Tests/Client/NullDriverTest.php +++ /dev/null @@ -1,200 +0,0 @@ -createMessageProducer(); - $producer - ->expects(self::once()) - ->method('send') - ->with(self::identicalTo($topic), self::identicalTo($transportMessage)) - ; - - $context = $this->createContextMock(); - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($topic) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - - $driver = new NullDriver($context, $config); - - $driver->sendToRouter(new Message()); - } - - public function testShouldSendMessageToProcessor() - { - $config = new Config('', '', '', '', '', ''); - $queue = new NullQueue(''); - - $transportMessage = new NullMessage(); - - $producer = $this->createMessageProducer(); - $producer - ->expects(self::once()) - ->method('send') - ->with(self::identicalTo($queue), self::identicalTo($transportMessage)) - ; - - $context = $this->createContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - - $driver = new NullDriver($context, $config); - - $driver->sendToProcessor(new Message()); - } - - public function testShouldConvertClientMessageToTransportMessage() - { - $config = new Config('', '', '', '', '', ''); - - $message = new Message(); - $message->setBody('theBody'); - $message->setContentType('theContentType'); - $message->setMessageId('theMessageId'); - $message->setTimestamp(12345); - $message->setDelay(123); - $message->setExpire(345); - $message->setPriority(MessagePriority::LOW); - $message->setHeaders(['theHeaderFoo' => 'theFoo']); - $message->setProperties(['thePropertyBar' => 'theBar']); - - $transportMessage = new NullMessage(); - - $context = $this->createContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new NullDriver($context, $config); - - $transportMessage = $driver->createTransportMessage($message); - - self::assertSame('theBody', $transportMessage->getBody()); - self::assertSame([ - 'theHeaderFoo' => 'theFoo', - 'content_type' => 'theContentType', - 'expiration' => 345, - 'delay' => 123, - 'priority' => MessagePriority::LOW, - 'timestamp' => 12345, - 'message_id' => 'theMessageId', - ], $transportMessage->getHeaders()); - self::assertSame([ - 'thePropertyBar' => 'theBar', - ], $transportMessage->getProperties()); - } - - public function testShouldConvertTransportMessageToClientMessage() - { - $config = new Config('', '', '', '', '', ''); - - $message = new NullMessage(); - $message->setBody('theBody'); - $message->setHeaders(['theHeaderFoo' => 'theFoo']); - $message->setTimestamp(12345); - $message->setMessageId('theMessageId'); - $message->setHeader('priority', MessagePriority::LOW); - $message->setHeader('content_type', 'theContentType'); - $message->setHeader('delay', 123); - $message->setHeader('expiration', 345); - $message->setProperties(['thePropertyBar' => 'theBar']); - - $driver = new NullDriver($this->createContextMock(), $config); - - $clientMessage = $driver->createClientMessage($message); - - self::assertSame('theBody', $clientMessage->getBody()); - self::assertSame(MessagePriority::LOW, $clientMessage->getPriority()); - self::assertSame('theContentType', $clientMessage->getContentType()); - self::assertSame(123, $clientMessage->getDelay()); - self::assertSame(345, $clientMessage->getExpire()); - self::assertEquals([ - 'theHeaderFoo' => 'theFoo', - 'content_type' => 'theContentType', - 'expiration' => 345, - 'delay' => 123, - 'priority' => MessagePriority::LOW, - 'timestamp' => 12345, - 'message_id' => 'theMessageId', - ], $clientMessage->getHeaders()); - self::assertSame([ - 'thePropertyBar' => 'theBar', - ], $clientMessage->getProperties()); - } - - public function testShouldReturnConfigInstance() - { - $config = new Config('', '', '', '', '', ''); - - $driver = new NullDriver($this->createContextMock(), $config); - $result = $driver->getConfig(); - - self::assertSame($config, $result); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|NullContext - */ - private function createContextMock() - { - return $this->createMock(NullContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|NullProducer - */ - private function createMessageProducer() - { - return $this->createMock(NullProducer::class); - } -} diff --git a/pkg/enqueue/Tests/Client/PostSendTest.php b/pkg/enqueue/Tests/Client/PostSendTest.php new file mode 100644 index 000000000..ba51710e7 --- /dev/null +++ b/pkg/enqueue/Tests/Client/PostSendTest.php @@ -0,0 +1,112 @@ +createProducerMock(); + $expectedDriver = $this->createDriverMock(); + $expectedDestination = $this->createDestinationMock(); + $expectedTransportMessage = $this->createTransportMessageMock(); + + $context = new PostSend( + $expectedMessage, + $expectedProducer, + $expectedDriver, + $expectedDestination, + $expectedTransportMessage + ); + + $this->assertSame($expectedMessage, $context->getMessage()); + $this->assertSame($expectedProducer, $context->getProducer()); + $this->assertSame($expectedDriver, $context->getDriver()); + $this->assertSame($expectedDestination, $context->getTransportDestination()); + $this->assertSame($expectedTransportMessage, $context->getTransportMessage()); + } + + public function testShouldAllowGetCommand() + { + $message = new Message(); + $message->setProperty(Config::COMMAND, 'theCommand'); + + $context = new PostSend( + $message, + $this->createProducerMock(), + $this->createDriverMock(), + $this->createDestinationMock(), + $this->createTransportMessageMock() + ); + + $this->assertFalse($context->isEvent()); + $this->assertSame('theCommand', $context->getCommand()); + } + + public function testShouldAllowGetTopic() + { + $message = new Message(); + $message->setProperty(Config::TOPIC, 'theTopic'); + + $context = new PostSend( + $message, + $this->createProducerMock(), + $this->createDriverMock(), + $this->createDestinationMock(), + $this->createTransportMessageMock() + ); + + $this->assertTrue($context->isEvent()); + $this->assertSame('theTopic', $context->getTopic()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverMock(): DriverInterface + { + return $this->createMock(DriverInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createProducerMock(): ProducerInterface + { + return $this->createMock(ProducerInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Destination + */ + private function createDestinationMock(): Destination + { + return $this->createMock(Destination::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|TransportMessage + */ + private function createTransportMessageMock(): TransportMessage + { + return $this->createMock(TransportMessage::class); + } +} diff --git a/pkg/enqueue/Tests/Client/PreSendTest.php b/pkg/enqueue/Tests/Client/PreSendTest.php new file mode 100644 index 000000000..01a7e5055 --- /dev/null +++ b/pkg/enqueue/Tests/Client/PreSendTest.php @@ -0,0 +1,116 @@ +createProducerMock(); + $expectedDriver = $this->createDriverMock(); + + $context = new PreSend( + $expectedCommandOrTopic, + $expectedMessage, + $expectedProducer, + $expectedDriver + ); + + $this->assertSame($expectedCommandOrTopic, $context->getTopic()); + $this->assertSame($expectedCommandOrTopic, $context->getCommand()); + $this->assertSame($expectedMessage, $context->getMessage()); + $this->assertSame($expectedProducer, $context->getProducer()); + $this->assertSame($expectedDriver, $context->getDriver()); + + $this->assertEquals($expectedMessage, $context->getOriginalMessage()); + $this->assertNotSame($expectedMessage, $context->getOriginalMessage()); + } + + public function testCouldChangeTopic() + { + $context = new PreSend( + 'aCommandOrTopic', + new Message(), + $this->createProducerMock(), + $this->createDriverMock() + ); + + // guard + $this->assertSame('aCommandOrTopic', $context->getTopic()); + + $context->changeTopic('theChangedTopic'); + + $this->assertSame('theChangedTopic', $context->getTopic()); + } + + public function testCouldChangeCommand() + { + $context = new PreSend( + 'aCommandOrTopic', + new Message(), + $this->createProducerMock(), + $this->createDriverMock() + ); + + // guard + $this->assertSame('aCommandOrTopic', $context->getCommand()); + + $context->changeCommand('theChangedCommand'); + + $this->assertSame('theChangedCommand', $context->getCommand()); + } + + public function testCouldChangeBody() + { + $context = new PreSend( + 'aCommandOrTopic', + new Message('aBody'), + $this->createProducerMock(), + $this->createDriverMock() + ); + + // guard + $this->assertSame('aBody', $context->getMessage()->getBody()); + $this->assertNull($context->getMessage()->getContentType()); + + $context->changeBody('theChangedBody'); + $this->assertSame('theChangedBody', $context->getMessage()->getBody()); + $this->assertNull($context->getMessage()->getContentType()); + + $context->changeBody('theChangedBodyAgain', 'foo/bar'); + $this->assertSame('theChangedBodyAgain', $context->getMessage()->getBody()); + $this->assertSame('foo/bar', $context->getMessage()->getContentType()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverMock(): DriverInterface + { + return $this->createMock(DriverInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createProducerMock(): ProducerInterface + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/ProducerSendCommandTest.php b/pkg/enqueue/Tests/Client/ProducerSendCommandTest.php new file mode 100644 index 000000000..9500e9d62 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ProducerSendCommandTest.php @@ -0,0 +1,537 @@ +createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + $expectedProperties = [ + 'enqueue.command' => 'command', + ]; + + self::assertEquals($expectedProperties, $message->getProperties()); + } + + public function testShouldSendCommandWithReply() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + + $expectedPromiseMock = $this->createMock(Promise::class); + + $rpcFactoryMock = $this->createRpcFactoryMock(); + $rpcFactoryMock + ->expects($this->once()) + ->method('createReplyTo') + ->willReturn('theReplyQueue') + ; + $rpcFactoryMock + ->expects($this->once()) + ->method('createPromise') + ->with( + 'theReplyQueue', + $this->logicalNot($this->isEmpty()), + 60000 + ) + ->willReturn($expectedPromiseMock) + ; + + $producer = new Producer($driver, $rpcFactoryMock); + $actualPromise = $producer->sendCommand('command', $message, true); + + $this->assertSame($expectedPromiseMock, $actualPromise); + + self::assertEquals('theReplyQueue', $message->getReplyTo()); + self::assertNotEmpty($message->getCorrelationId()); + } + + public function testShouldSendCommandWithReplyAndCustomReplyQueueAndCorrelationId() + { + $message = new Message(); + $message->setReplyTo('theCustomReplyQueue'); + $message->setCorrelationId('theCustomCorrelationId'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + + $expectedPromiseMock = $this->createMock(Promise::class); + + $rpcFactoryMock = $this->createRpcFactoryMock(); + $rpcFactoryMock + ->expects($this->never()) + ->method('createReplyTo') + ; + $rpcFactoryMock + ->expects($this->once()) + ->method('createPromise') + ->with( + 'theCustomReplyQueue', + 'theCustomCorrelationId', + 60000 + ) + ->willReturn($expectedPromiseMock) + ; + + $producer = new Producer($driver, $rpcFactoryMock); + $actualPromise = $producer->sendCommand('command', $message, true); + + $this->assertSame($expectedPromiseMock, $actualPromise); + + self::assertEquals('theCustomReplyQueue', $message->getReplyTo()); + self::assertSame('theCustomCorrelationId', $message->getCorrelationId()); + } + + public function testShouldOverwriteExpectedMessageProperties() + { + $message = new Message(); + $message->setProperty(Config::COMMAND, 'commandShouldBeOverwritten'); + $message->setScope('scopeShouldBeOverwritten'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('expectedCommand', $message); + + $expectedProperties = [ + 'enqueue.command' => 'expectedCommand', + ]; + + self::assertEquals($expectedProperties, $message->getProperties()); + self::assertSame(Message::SCOPE_APP, $message->getScope()); + } + + public function testShouldSendCommandWithoutPriorityByDefault() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertNull($message->getPriority()); + } + + public function testShouldSendCommandWithCustomPriority() + { + $message = new Message(); + $message->setPriority(MessagePriority::HIGH); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertSame(MessagePriority::HIGH, $message->getPriority()); + } + + public function testShouldSendCommandWithGeneratedMessageId() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertNotEmpty($message->getMessageId()); + } + + public function testShouldSendCommandWithCustomMessageId() + { + $message = new Message(); + $message->setMessageId('theCustomMessageId'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertSame('theCustomMessageId', $message->getMessageId()); + } + + public function testShouldSendCommandWithGeneratedTimestamp() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertNotEmpty($message->getTimestamp()); + } + + public function testShouldSendCommandWithCustomTimestamp() + { + $message = new Message(); + $message->setTimestamp('theCustomTimestamp'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + + self::assertSame('theCustomTimestamp', $message->getTimestamp()); + } + + public function testShouldSerializeMessageToJsonByDefault() + { + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) { + $this->assertSame('{"foo":"fooVal"}', $message->getBody()); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', ['foo' => 'fooVal']); + } + + public function testShouldSerializeMessageByCustomExtension() + { + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) { + $this->assertSame('theCommandBodySerializedByCustomExtension', $message->getBody()); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock(), new ChainExtension([new CustomPrepareBodyClientExtension()])); + $producer->sendCommand('command', ['foo' => 'fooVal']); + } + + public function testShouldSendCommandToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) { + self::assertSame('aBody', $message->getBody()); + self::assertNull($message->getProperty(Config::PROCESSOR)); + self::assertSame('command', $message->getProperty(Config::COMMAND)); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendCommand('command', $message); + } + + public function testThrowWhenProcessorNamePropertySetToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + $message->setProperty(Config::PROCESSOR, 'aCustomProcessor'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The enqueue.processor property must not be set.'); + $producer->sendCommand('command', $message); + } + + public function testShouldCallPreSendCommandExtensionMethodWhenSendToBus() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_MESSAGE_BUS); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPreSendCommand') + ->willReturnCallback(function (PreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('command', $context->getCommand()); + + $this->assertEquals($message, $context->getOriginalMessage()); + $this->assertNotSame($message, $context->getOriginalMessage()); + }); + + $extension + ->expects($this->never()) + ->method('onPreSendEvent') + ; + + $producer->sendCommand('command', $message); + } + + public function testShouldCallPreSendCommandExtensionMethodWhenSendToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPreSendCommand') + ->willReturnCallback(function (PreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('command', $context->getCommand()); + + $this->assertEquals($message, $context->getOriginalMessage()); + $this->assertNotSame($message, $context->getOriginalMessage()); + }); + + $extension + ->expects($this->never()) + ->method('onPreSendEvent') + ; + + $producer->sendCommand('command', $message); + } + + public function testShouldCallPreDriverSendExtensionMethod() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onDriverPreSend') + ->willReturnCallback(function (DriverPreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('command', $context->getCommand()); + + $this->assertTrue($context->isEvent()); + }); + + $producer->sendCommand('command', $message); + } + + public function testShouldCallPostSendExtensionMethod() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPostSend') + ->willReturnCallback(function (PostSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('command', $context->getCommand()); + + $this->assertFalse($context->isEvent()); + }); + + $producer->sendCommand('command', $message); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createRpcFactoryMock(): RpcFactory + { + return $this->createMock(RpcFactory::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverStub(): DriverInterface + { + $config = new Config( + 'a_prefix', + '.', + 'an_app', + 'a_router_topic', + 'a_router_queue', + 'a_default_processor_queue', + 'a_router_processor_name', + [], + [] + ); + + $driverMock = $this->createMock(DriverInterface::class); + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn($config) + ; + + return $driverMock; + } + + private function createDriverSendResult(): DriverSendResult + { + return new DriverSendResult( + $this->createMock(Destination::class), + $this->createMock(TransportMessage::class) + ); + } +} diff --git a/pkg/enqueue/Tests/Client/ProducerSendEventTest.php b/pkg/enqueue/Tests/Client/ProducerSendEventTest.php new file mode 100644 index 000000000..c92b49560 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ProducerSendEventTest.php @@ -0,0 +1,557 @@ +createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + $expectedProperties = [ + 'enqueue.topic' => 'topic', + ]; + + self::assertEquals($expectedProperties, $message->getProperties()); + } + + public function testShouldOverwriteTopicProperty() + { + $message = new Message(); + $message->setProperty(Config::TOPIC, 'topicShouldBeOverwritten'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('expectedTopic', $message); + + $expectedProperties = [ + 'enqueue.topic' => 'expectedTopic', + ]; + + self::assertEquals($expectedProperties, $message->getProperties()); + } + + public function testShouldSendEventWithoutPriorityByDefault() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertNull($message->getPriority()); + } + + public function testShouldSendEventWithCustomPriority() + { + $message = new Message(); + $message->setPriority(MessagePriority::HIGH); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertSame(MessagePriority::HIGH, $message->getPriority()); + } + + public function testShouldSendEventWithGeneratedMessageId() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertNotEmpty($message->getMessageId()); + } + + public function testShouldSendEventWithCustomMessageId() + { + $message = new Message(); + $message->setMessageId('theCustomMessageId'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertSame('theCustomMessageId', $message->getMessageId()); + } + + public function testShouldSendEventWithGeneratedTimestamp() + { + $message = new Message(); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertNotEmpty($message->getTimestamp()); + } + + public function testShouldSendEventWithCustomTimestamp() + { + $message = new Message(); + $message->setTimestamp('theCustomTimestamp'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->with(self::identicalTo($message)) + ->willReturn($this->createDriverSendResult()) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + + self::assertSame('theCustomTimestamp', $message->getTimestamp()); + } + + public function testShouldSerializeMessageToJsonByDefault() + { + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->willReturnCallback(function (Message $message) { + $this->assertSame('{"foo":"fooVal"}', $message->getBody()); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', ['foo' => 'fooVal']); + } + + public function testShouldSerializeMessageByCustomExtension() + { + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->willReturnCallback(function (Message $message) { + $this->assertSame('theEventBodySerializedByCustomExtension', $message->getBody()); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock(), new ChainExtension([new CustomPrepareBodyClientExtension()])); + $producer->sendEvent('topic', ['foo' => 'fooVal']); + } + + public function testThrowIfSendEventToMessageBusWithProcessorNamePropertySet() + { + $message = new Message(); + $message->setBody(''); + $message->setProperty(Config::PROCESSOR, 'aProcessor'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The enqueue.processor property must not be set.'); + $producer->sendEvent('topic', $message); + } + + public function testShouldSendEventToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) { + self::assertSame('aBody', $message->getBody()); + + // null means a driver sends a message to router processor. + self::assertNull($message->getProperty(Config::PROCESSOR)); + + return $this->createDriverSendResult(); + }) + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + $producer->sendEvent('topic', $message); + } + + public function testThrowWhenProcessorNamePropertySetToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + $message->setProperty(Config::PROCESSOR, 'aCustomProcessor'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The enqueue.processor property must not be set.'); + $producer->sendEvent('topic', $message); + } + + public function testThrowIfUnSupportedScopeGivenOnSend() + { + $message = new Message(); + $message->setScope('iDontKnowScope'); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->never()) + ->method('sendToRouter') + ; + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ; + + $producer = new Producer($driver, $this->createRpcFactoryMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message scope "iDontKnowScope" is not supported.'); + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPreSendEventExtensionMethodWhenSendToBus() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_MESSAGE_BUS); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPreSendEvent') + ->willReturnCallback(function (PreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertEquals($message, $context->getOriginalMessage()); + $this->assertNotSame($message, $context->getOriginalMessage()); + }); + + $extension + ->expects($this->never()) + ->method('onPreSendCommand') + ; + + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPreSendEventExtensionMethodWhenSendToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPreSendEvent') + ->willReturnCallback(function (PreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertEquals($message, $context->getOriginalMessage()); + $this->assertNotSame($message, $context->getOriginalMessage()); + }); + + $extension + ->expects($this->never()) + ->method('onPreSendCommand') + ; + + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPreDriverSendExtensionMethodWhenSendToMessageBus() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_MESSAGE_BUS); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onDriverPreSend') + ->willReturnCallback(function (DriverPreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertTrue($context->isEvent()); + }); + + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPreDriverSendExtensionMethodWhenSendToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onDriverPreSend') + ->willReturnCallback(function (DriverPreSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertTrue($context->isEvent()); + }); + + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPostSendExtensionMethodWhenSendToMessageBus() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_MESSAGE_BUS); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToRouter') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPostSend') + ->willReturnCallback(function (PostSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertTrue($context->isEvent()); + }); + + $producer->sendEvent('topic', $message); + } + + public function testShouldCallPostSendExtensionMethodWhenSendToApplicationRouter() + { + $message = new Message(); + $message->setBody('aBody'); + $message->setScope(Message::SCOPE_APP); + + $driver = $this->createDriverStub(); + $driver + ->expects($this->once()) + ->method('sendToProcessor') + ->willReturn($this->createDriverSendResult()) + ; + + $extension = $this->createMock(ExtensionInterface::class); + + $producer = new Producer($driver, $this->createRpcFactoryMock(), $extension); + + $extension + ->expects($this->at(0)) + ->method('onPostSend') + ->willReturnCallback(function (PostSend $context) use ($message, $producer, $driver) { + $this->assertSame($message, $context->getMessage()); + $this->assertSame($producer, $context->getProducer()); + $this->assertSame($driver, $context->getDriver()); + $this->assertSame('topic', $context->getTopic()); + + $this->assertTrue($context->isEvent()); + }); + + $producer->sendEvent('topic', $message); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createRpcFactoryMock(): RpcFactory + { + return $this->createMock(RpcFactory::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverStub(): DriverInterface + { + $config = new Config( + 'a_prefix', + '.', + 'an_app', + 'a_router_topic', + 'a_router_queue', + 'a_default_processor_queue', + 'a_router_processor_name', + [], + [] + ); + + $driverMock = $this->createMock(DriverInterface::class); + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn($config) + ; + + return $driverMock; + } + + private function createDriverSendResult(): DriverSendResult + { + return new DriverSendResult( + $this->createMock(Destination::class), + $this->createMock(TransportMessage::class) + ); + } +} diff --git a/pkg/enqueue/Tests/Client/ProducerTest.php b/pkg/enqueue/Tests/Client/ProducerTest.php new file mode 100644 index 000000000..23b004ac3 --- /dev/null +++ b/pkg/enqueue/Tests/Client/ProducerTest.php @@ -0,0 +1,41 @@ +createMock(RpcFactory::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverMock(): DriverInterface + { + return $this->createMock(DriverInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/ResourcesTest.php b/pkg/enqueue/Tests/Client/ResourcesTest.php new file mode 100644 index 000000000..e79fb9dda --- /dev/null +++ b/pkg/enqueue/Tests/Client/ResourcesTest.php @@ -0,0 +1,159 @@ +assertTrue($rc->isFinal()); + } + + public function testShouldConstructorBePrivate() + { + $rc = new \ReflectionClass(Resources::class); + + $this->assertTrue($rc->getConstructor()->isPrivate()); + } + + public function testShouldGetAvailableDriverInExpectedFormat() + { + $availableDrivers = Resources::getAvailableDrivers(); + + self::assertIsArray($availableDrivers); + $this->assertGreaterThan(0, count($availableDrivers)); + + $driverInfo = $availableDrivers[0]; + + $this->assertArrayHasKey('driverClass', $driverInfo); + $this->assertSame(AmqpDriver::class, $driverInfo['driverClass']); + + $this->assertArrayHasKey('schemes', $driverInfo); + $this->assertSame(['amqp', 'amqps'], $driverInfo['schemes']); + + $this->assertArrayHasKey('requiredSchemeExtensions', $driverInfo); + $this->assertSame([], $driverInfo['requiredSchemeExtensions']); + + $this->assertArrayHasKey('packages', $driverInfo); + $this->assertSame(['enqueue/enqueue', 'enqueue/amqp-bunny'], $driverInfo['packages']); + } + + public function testShouldGetAvailableDriverWithRequiredExtensionInExpectedFormat() + { + $availableDrivers = Resources::getAvailableDrivers(); + + self::assertIsArray($availableDrivers); + $this->assertGreaterThan(0, count($availableDrivers)); + + $driverInfo = $availableDrivers[1]; + + $this->assertArrayHasKey('driverClass', $driverInfo); + $this->assertSame(RabbitMqDriver::class, $driverInfo['driverClass']); + + $this->assertArrayHasKey('schemes', $driverInfo); + $this->assertSame(['amqp', 'amqps'], $driverInfo['schemes']); + + $this->assertArrayHasKey('requiredSchemeExtensions', $driverInfo); + $this->assertSame(['rabbitmq'], $driverInfo['requiredSchemeExtensions']); + + $this->assertArrayHasKey('packages', $driverInfo); + $this->assertSame(['enqueue/enqueue', 'enqueue/amqp-bunny'], $driverInfo['packages']); + } + + public function testShouldGetKnownDriversInExpectedFormat() + { + $knownDrivers = Resources::getAvailableDrivers(); + + self::assertIsArray($knownDrivers); + $this->assertGreaterThan(0, count($knownDrivers)); + + $driverInfo = $knownDrivers[0]; + + $this->assertArrayHasKey('driverClass', $driverInfo); + $this->assertSame(AmqpDriver::class, $driverInfo['driverClass']); + + $this->assertArrayHasKey('schemes', $driverInfo); + $this->assertSame(['amqp', 'amqps'], $driverInfo['schemes']); + + $this->assertArrayHasKey('requiredSchemeExtensions', $driverInfo); + $this->assertSame([], $driverInfo['requiredSchemeExtensions']); + + $this->assertArrayHasKey('packages', $driverInfo); + $this->assertSame(['enqueue/enqueue', 'enqueue/amqp-bunny'], $driverInfo['packages']); + } + + public function testThrowsIfDriverClassNotImplementDriverFactoryInterfaceOnAddDriver() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The driver class "stdClass" must implement "Enqueue\Client\DriverInterface" interface.'); + + Resources::addDriver(\stdClass::class, [], [], ['foo']); + } + + public function testThrowsIfNoSchemesProvidedOnAddDriver() + { + $driverClass = $this->getMockClass(DriverInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Schemes could not be empty.'); + + Resources::addDriver($driverClass, [], [], ['foo']); + } + + public function testThrowsIfNoPackageProvidedOnAddDriver() + { + $driverClass = $this->getMockClass(DriverInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Packages could not be empty.'); + + Resources::addDriver($driverClass, ['foo'], [], []); + } + + public function testShouldAllowRegisterDriverThatIsNotInstalled() + { + Resources::addDriver('theDriverClass', ['foo'], ['barExtension'], ['foo']); + + $availableDrivers = Resources::getKnownDrivers(); + + $driverInfo = end($availableDrivers); + + $this->assertSame('theDriverClass', $driverInfo['driverClass']); + } + + public function testShouldAllowGetPreviouslyRegisteredDriver() + { + $driverClass = $this->getMockClass(DriverInterface::class); + + Resources::addDriver( + $driverClass, + ['fooscheme', 'barscheme'], + ['fooextension', 'barextension'], + ['foo/bar'] + ); + + $availableDrivers = Resources::getAvailableDrivers(); + + $driverInfo = end($availableDrivers); + + $this->assertArrayHasKey('driverClass', $driverInfo); + $this->assertSame($driverClass, $driverInfo['driverClass']); + + $this->assertArrayHasKey('schemes', $driverInfo); + $this->assertSame(['fooscheme', 'barscheme'], $driverInfo['schemes']); + + $this->assertArrayHasKey('requiredSchemeExtensions', $driverInfo); + $this->assertSame(['fooextension', 'barextension'], $driverInfo['requiredSchemeExtensions']); + + $this->assertArrayHasKey('packages', $driverInfo); + $this->assertSame(['foo/bar'], $driverInfo['packages']); + } +} diff --git a/pkg/enqueue/Tests/Client/RouterProcessorTest.php b/pkg/enqueue/Tests/Client/RouterProcessorTest.php index 498e31fb5..7d2971189 100644 --- a/pkg/enqueue/Tests/Client/RouterProcessorTest.php +++ b/pkg/enqueue/Tests/Client/RouterProcessorTest.php @@ -4,105 +4,218 @@ use Enqueue\Client\Config; use Enqueue\Client\DriverInterface; +use Enqueue\Client\DriverSendResult; use Enqueue\Client\Message; +use Enqueue\Client\Route; +use Enqueue\Client\RouteCollection; use Enqueue\Client\RouterProcessor; use Enqueue\Consumption\Result; -use Enqueue\Transport\Null\NullContext; -use Enqueue\Transport\Null\NullMessage; - -class RouterProcessorTest extends \PHPUnit_Framework_TestCase +use Enqueue\Null\NullContext; +use Enqueue\Null\NullMessage; +use Enqueue\Test\ClassExtensionTrait; +use Enqueue\Test\ReadAttributeTrait; +use Interop\Queue\Destination; +use Interop\Queue\Message as TransportMessage; +use Interop\Queue\Processor; +use PHPUnit\Framework\TestCase; + +class RouterProcessorTest extends TestCase { - public function testCouldBeConstructedWithDriverAsFirstArgument() + use ClassExtensionTrait; + use ReadAttributeTrait; + + public function testShouldImplementProcessorInterface() + { + $this->assertClassImplements(Processor::class, RouterProcessor::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(RouterProcessor::class); + } + + public function testCouldBeConstructedWithDriver() { - new RouterProcessor($this->createDriverMock()); + $driver = $this->createDriverStub(); + + $processor = new RouterProcessor($driver); + + $this->assertAttributeSame($driver, 'driver', $processor); } - public function testCouldBeConstructedWithSessionAndRoutes() + public function testShouldRejectIfTopicNotSet() { - $routes = [ - 'aTopicName' => [['aProcessorName', 'aQueueName']], - 'anotherTopicName' => [['aProcessorName', 'aQueueName']], - ]; + $router = new RouterProcessor($this->createDriverStub()); - $router = new RouterProcessor($this->createDriverMock(), $routes); + $result = $router->process(new NullMessage(), new NullContext()); - $this->assertAttributeEquals($routes, 'routes', $router); + $this->assertEquals(Result::REJECT, $result->getStatus()); + $this->assertEquals('Topic property "enqueue.topic" is required but not set or empty.', $result->getReason()); } - public function testShouldThrowExceptionIfTopicNameParameterIsNotSet() + public function testShouldRejectIfCommandSet() { - $router = new RouterProcessor($this->createDriverMock()); + $router = new RouterProcessor($this->createDriverStub()); - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Got message without required parameter: "enqueue.topic_name"'); + $message = new NullMessage(); + $message->setProperty(Config::COMMAND, 'aCommand'); - $router->process(new NullMessage(), new NullContext()); + $result = $router->process($message, new NullContext()); + + $this->assertEquals(Result::REJECT, $result->getStatus()); + $this->assertEquals('Unexpected command "aCommand" got. Command must not go to the router.', $result->getReason()); } - public function testShouldRouteOriginalMessageToRecipient() + public function testShouldRouteOriginalMessageToAllRecipients() { $message = new NullMessage(); $message->setBody('theBody'); $message->setHeaders(['aHeader' => 'aHeaderVal']); - $message->setProperties(['aProp' => 'aPropVal', Config::PARAMETER_TOPIC_NAME => 'theTopicName']); + $message->setProperties(['aProp' => 'aPropVal', Config::TOPIC => 'theTopicName']); - $clientMessage = new Message(); + /** @var Message[] $routedMessages */ + $routedMessages = new \ArrayObject(); - $routedMessage = null; + $routeCollection = new RouteCollection([ + new Route('theTopicName', Route::TOPIC, 'aFooProcessor'), + new Route('theTopicName', Route::TOPIC, 'aBarProcessor'), + new Route('theTopicName', Route::TOPIC, 'aBazProcessor'), + ]); - $driver = $this->createDriverMock(); + $driver = $this->createDriverStub($routeCollection); $driver - ->expects($this->once()) + ->expects($this->exactly(3)) ->method('sendToProcessor') - ->with($this->identicalTo($clientMessage)) + ->willReturnCallback(function (Message $message) use ($routedMessages) { + $routedMessages->append($message); + + return $this->createDriverSendResult(); + }) ; $driver - ->expects($this->once()) + ->expects($this->exactly(3)) ->method('createClientMessage') - ->willReturnCallback(function (NullMessage $message) use (&$routedMessage, $clientMessage) { - $routedMessage = $message; + ->willReturnCallback(function (NullMessage $message) { + return new Message($message->getBody(), $message->getProperties(), $message->getHeaders()); + }) + ; + + $processor = new RouterProcessor($driver); + + $result = $processor->process($message, new NullContext()); + + $this->assertEquals(Result::ACK, $result->getStatus()); + $this->assertEquals('Routed to "3" event subscribers', $result->getReason()); + + $this->assertContainsOnly(Message::class, $routedMessages); + $this->assertCount(3, $routedMessages); + + $this->assertSame('aFooProcessor', $routedMessages[0]->getProperty(Config::PROCESSOR)); + $this->assertSame('aBarProcessor', $routedMessages[1]->getProperty(Config::PROCESSOR)); + $this->assertSame('aBazProcessor', $routedMessages[2]->getProperty(Config::PROCESSOR)); + } + + public function testShouldDoNothingIfNoRoutes() + { + $message = new NullMessage(); + $message->setBody('theBody'); + $message->setHeaders(['aHeader' => 'aHeaderVal']); + $message->setProperties(['aProp' => 'aPropVal', Config::TOPIC => 'theTopicName']); - return $clientMessage; + /** @var Message[] $routedMessages */ + $routedMessages = new \ArrayObject(); + + $routeCollection = new RouteCollection([]); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->never()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) use ($routedMessages) { + $routedMessages->append($message); + }) + ; + $driver + ->expects($this->never()) + ->method('createClientMessage') + ->willReturnCallback(function (NullMessage $message) { + return new Message($message->getBody(), $message->getProperties(), $message->getHeaders()); }) ; - $routes = [ - 'theTopicName' => [['aFooProcessor', 'aQueueName']], - ]; + $processor = new RouterProcessor($driver); - $router = new RouterProcessor($driver, $routes); + $result = $processor->process($message, new NullContext()); - $result = $router->process($message, new NullContext()); + $this->assertEquals(Result::ACK, $result->getStatus()); + $this->assertEquals('Routed to "0" event subscribers', $result->getReason()); - $this->assertEquals(Result::ACK, $result); - $this->assertEquals([ - 'aProp' => 'aPropVal', - 'enqueue.topic_name' => 'theTopicName', - 'enqueue.processor_name' => 'aFooProcessor', - 'enqueue.processor_queue_name' => 'aQueueName', - ], $routedMessage->getProperties()); + $this->assertCount(0, $routedMessages); } - public function testShouldAddRoute() + public function testShouldDoNotModifyOriginalMessage() { - $router = new RouterProcessor($this->createDriverMock(), []); + $message = new NullMessage(); + $message->setBody('theBody'); + $message->setHeaders(['aHeader' => 'aHeaderVal']); + $message->setProperties(['aProp' => 'aPropVal', Config::TOPIC => 'theTopicName']); - $this->assertAttributeSame([], 'routes', $router); + /** @var Message[] $routedMessages */ + $routedMessages = new \ArrayObject(); - $router->add('theTopicName', 'theQueueName', 'aProcessorName'); + $routeCollection = new RouteCollection([ + new Route('theTopicName', Route::TOPIC, 'aFooProcessor'), + new Route('theTopicName', Route::TOPIC, 'aBarProcessor'), + ]); - $this->assertAttributeSame([ - 'theTopicName' => [ - ['aProcessorName', 'theQueueName'], - ], - ], 'routes', $router); + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->atLeastOnce()) + ->method('sendToProcessor') + ->willReturnCallback(function (Message $message) use ($routedMessages) { + $routedMessages->append($message); + + return $this->createDriverSendResult(); + }); + $driver + ->expects($this->atLeastOnce()) + ->method('createClientMessage') + ->willReturnCallback(function (NullMessage $message) { + return new Message($message->getBody(), $message->getProperties(), $message->getHeaders()); + }); + + $processor = new RouterProcessor($driver); + + $result = $processor->process($message, new NullContext()); + + // guard + $this->assertEquals(Result::ACK, $result->getStatus()); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal', Config::TOPIC => 'theTopicName'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface */ - protected function createDriverMock() + private function createDriverStub(?RouteCollection $routeCollection = null): DriverInterface + { + $driver = $this->createMock(DriverInterface::class); + $driver + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection ?? new RouteCollection([])) + ; + + return $driver; + } + + private function createDriverSendResult(): DriverSendResult { - return $this->createMock(DriverInterface::class); + return new DriverSendResult( + $this->createMock(Destination::class), + $this->createMock(TransportMessage::class) + ); } } diff --git a/pkg/enqueue/Tests/Client/SpoolProducerTest.php b/pkg/enqueue/Tests/Client/SpoolProducerTest.php new file mode 100644 index 000000000..014fe4962 --- /dev/null +++ b/pkg/enqueue/Tests/Client/SpoolProducerTest.php @@ -0,0 +1,158 @@ +createProducerMock(); + $realProducer + ->expects($this->never()) + ->method('sendEvent') + ; + $realProducer + ->expects($this->never()) + ->method('sendCommand') + ; + + $producer = new SpoolProducer($realProducer); + $producer->sendEvent('foo_topic', $message); + $producer->sendEvent('bar_topic', $message); + } + + public function testShouldQueueCommandMessageOnSend() + { + $message = new Message(); + + $realProducer = $this->createProducerMock(); + $realProducer + ->expects($this->never()) + ->method('sendEvent') + ; + $realProducer + ->expects($this->never()) + ->method('sendCommand') + ; + + $producer = new SpoolProducer($realProducer); + $producer->sendCommand('foo_command', $message); + $producer->sendCommand('bar_command', $message); + } + + public function testShouldSendQueuedEventMessagesOnFlush() + { + $message = new Message(); + $message->setScope('third'); + + $realProducer = $this->createProducerMock(); + $realProducer + ->expects($this->at(0)) + ->method('sendEvent') + ->with('foo_topic', 'first') + ; + $realProducer + ->expects($this->at(1)) + ->method('sendEvent') + ->with('bar_topic', ['second']) + ; + $realProducer + ->expects($this->at(2)) + ->method('sendEvent') + ->with('baz_topic', $this->identicalTo($message)) + ; + $realProducer + ->expects($this->never()) + ->method('sendCommand') + ; + + $producer = new SpoolProducer($realProducer); + + $producer->sendEvent('foo_topic', 'first'); + $producer->sendEvent('bar_topic', ['second']); + $producer->sendEvent('baz_topic', $message); + + $producer->flush(); + } + + public function testShouldSendQueuedCommandMessagesOnFlush() + { + $message = new Message(); + $message->setScope('third'); + + $realProducer = $this->createProducerMock(); + $realProducer + ->expects($this->at(0)) + ->method('sendCommand') + ->with('foo_command', 'first') + ; + $realProducer + ->expects($this->at(1)) + ->method('sendCommand') + ->with('bar_command', ['second']) + ; + $realProducer + ->expects($this->at(2)) + ->method('sendCommand') + ->with('baz_command', $this->identicalTo($message)) + ; + + $producer = new SpoolProducer($realProducer); + + $producer->sendCommand('foo_command', 'first'); + $producer->sendCommand('bar_command', ['second']); + $producer->sendCommand('baz_command', $message); + + $producer->flush(); + } + + public function testShouldSendImmediatelyCommandMessageWithNeedReplyTrue() + { + $message = new Message(); + $message->setScope('third'); + + $promise = $this->createMock(Promise::class); + + $realProducer = $this->createProducerMock(); + $realProducer + ->expects($this->never()) + ->method('sendEvent') + ; + $realProducer + ->expects($this->once()) + ->method('sendCommand') + ->with('foo_command', 'first') + ->willReturn($promise) + ; + + $producer = new SpoolProducer($realProducer); + + $actualPromise = $producer->sendCommand('foo_command', 'first', true); + + $this->assertSame($promise, $actualPromise); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerInterface + */ + protected function createProducerMock() + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Client/TraceableMessageProducerTest.php b/pkg/enqueue/Tests/Client/TraceableMessageProducerTest.php deleted file mode 100644 index 683b4dc80..000000000 --- a/pkg/enqueue/Tests/Client/TraceableMessageProducerTest.php +++ /dev/null @@ -1,200 +0,0 @@ -assertClassImplements(MessageProducerInterface::class, TraceableMessageProducer::class); - } - - public function testCouldBeConstructedWithInternalMessageProducer() - { - new TraceableMessageProducer($this->createMessageProducer()); - } - - public function testShouldPassAllArgumentsToInternalMessageProducerSendMethod() - { - $topic = 'theTopic'; - $body = 'theBody'; - - $internalMessageProducer = $this->createMessageProducer(); - $internalMessageProducer - ->expects($this->once()) - ->method('send') - ->with($topic, $body) - ; - - $messageProducer = new TraceableMessageProducer($internalMessageProducer); - - $messageProducer->send($topic, $body); - } - - public function testShouldCollectInfoIfStringGivenAsMessage() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', 'aFooBody'); - - $this->assertSame([ - [ - 'topic' => 'aFooTopic', - 'body' => 'aFooBody', - 'headers' => [], - 'properties' => [], - 'priority' => null, - 'expire' => null, - 'delay' => null, - 'timestamp' => null, - 'contentType' => null, - 'messageId' => null, - ], - ], $messageProducer->getTraces()); - } - - public function testShouldCollectInfoIfArrayGivenAsMessage() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', ['foo' => 'fooVal', 'bar' => 'barVal']); - - $this->assertSame([ - [ - 'topic' => 'aFooTopic', - 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], - 'headers' => [], - 'properties' => [], - 'priority' => null, - 'expire' => null, - 'delay' => null, - 'timestamp' => null, - 'contentType' => null, - 'messageId' => null, - ], - ], $messageProducer->getTraces()); - } - - public function testShouldCollectInfoIfMessageObjectGivenAsMessage() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $message = new Message(); - $message->setBody(['foo' => 'fooVal', 'bar' => 'barVal']); - $message->setProperty('fooProp', 'fooVal'); - $message->setHeader('fooHeader', 'fooVal'); - $message->setContentType('theContentType'); - $message->setDelay('theDelay'); - $message->setExpire('theExpire'); - $message->setMessageId('theMessageId'); - $message->setPriority('theMessagePriority'); - $message->setTimestamp('theTimestamp'); - - $messageProducer->send('aFooTopic', $message); - - $this->assertSame([ - [ - 'topic' => 'aFooTopic', - 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], - 'headers' => ['fooHeader' => 'fooVal'], - 'properties' => ['fooProp' => 'fooVal'], - 'priority' => 'theMessagePriority', - 'expire' => 'theExpire', - 'delay' => 'theDelay', - 'timestamp' => 'theTimestamp', - 'contentType' => 'theContentType', - 'messageId' => 'theMessageId', - ], - ], $messageProducer->getTraces()); - } - - public function testShouldAllowGetInfoSentToSameTopic() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', 'aFooBody'); - $messageProducer->send('aFooTopic', 'aFooBody'); - - $this->assertArraySubset([ - ['topic' => 'aFooTopic', 'body' => 'aFooBody'], - ['topic' => 'aFooTopic', 'body' => 'aFooBody'], - ], $messageProducer->getTraces()); - } - - public function testShouldAllowGetInfoSentToDifferentTopics() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', 'aFooBody'); - $messageProducer->send('aBarTopic', 'aBarBody'); - - $this->assertArraySubset([ - ['topic' => 'aFooTopic', 'body' => 'aFooBody'], - ['topic' => 'aBarTopic', 'body' => 'aBarBody'], - ], $messageProducer->getTraces()); - } - - public function testShouldAllowGetInfoSentToSpecialTopicTopics() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', 'aFooBody'); - $messageProducer->send('aBarTopic', 'aBarBody'); - - $this->assertArraySubset([ - ['topic' => 'aFooTopic', 'body' => 'aFooBody'], - ], $messageProducer->getTopicTraces('aFooTopic')); - - $this->assertArraySubset([ - ['topic' => 'aBarTopic', 'body' => 'aBarBody'], - ], $messageProducer->getTopicTraces('aBarTopic')); - } - - public function testShouldNotStoreAnythingIfInternalMessageProducerThrowsException() - { - $internalMessageProducer = $this->createMessageProducer(); - $internalMessageProducer - ->expects($this->once()) - ->method('send') - ->willThrowException(new \Exception()) - ; - - $messageProducer = new TraceableMessageProducer($internalMessageProducer); - - $this->expectException(\Exception::class); - - try { - $messageProducer->send('aFooTopic', 'aFooBody'); - } finally { - $this->assertEmpty($messageProducer->getTraces()); - } - } - - public function testShouldAllowClearStoredTraces() - { - $messageProducer = new TraceableMessageProducer($this->createMessageProducer()); - - $messageProducer->send('aFooTopic', 'aFooBody'); - - //guard - $this->assertNotEmpty($messageProducer->getTraces()); - - $messageProducer->clearTraces(); - $this->assertSame([], $messageProducer->getTraces()); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducerInterface - */ - protected function createMessageProducer() - { - return $this->createMock(MessageProducerInterface::class); - } -} diff --git a/pkg/enqueue/Tests/Client/TraceableProducerTest.php b/pkg/enqueue/Tests/Client/TraceableProducerTest.php new file mode 100644 index 000000000..b0df066ce --- /dev/null +++ b/pkg/enqueue/Tests/Client/TraceableProducerTest.php @@ -0,0 +1,371 @@ +assertClassImplements(ProducerInterface::class, TraceableProducer::class); + } + + public function testShouldPassAllArgumentsToInternalEventMessageProducerSendMethod() + { + $topic = 'theTopic'; + $body = 'theBody'; + + $internalMessageProducer = $this->createProducerMock(); + $internalMessageProducer + ->expects($this->once()) + ->method('sendEvent') + ->with($topic, $body) + ; + + $producer = new TraceableProducer($internalMessageProducer); + + $producer->sendEvent($topic, $body); + } + + public function testShouldCollectInfoIfStringGivenAsEventMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', 'aFooBody'); + + Assert::assertArraySubset([ + [ + 'topic' => 'aFooTopic', + 'command' => null, + 'body' => 'aFooBody', + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldCollectInfoIfArrayGivenAsEventMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', ['foo' => 'fooVal', 'bar' => 'barVal']); + + Assert::assertArraySubset([ + [ + 'topic' => 'aFooTopic', + 'command' => null, + 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldCollectInfoIfEventMessageObjectGivenAsMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $message = new Message(); + $message->setBody(['foo' => 'fooVal', 'bar' => 'barVal']); + $message->setProperty('fooProp', 'fooVal'); + $message->setHeader('fooHeader', 'fooVal'); + $message->setContentType('theContentType'); + $message->setDelay('theDelay'); + $message->setExpire('theExpire'); + $message->setMessageId('theMessageId'); + $message->setPriority('theMessagePriority'); + $message->setTimestamp('theTimestamp'); + + $producer->sendEvent('aFooTopic', $message); + + Assert::assertArraySubset([ + [ + 'topic' => 'aFooTopic', + 'command' => null, + 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'headers' => ['fooHeader' => 'fooVal'], + 'properties' => ['fooProp' => 'fooVal'], + 'priority' => 'theMessagePriority', + 'expire' => 'theExpire', + 'delay' => 'theDelay', + 'timestamp' => 'theTimestamp', + 'contentType' => 'theContentType', + 'messageId' => 'theMessageId', + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldNotStoreAnythingIfInternalEventMessageProducerThrowsException() + { + $internalMessageProducer = $this->createProducerMock(); + $internalMessageProducer + ->expects($this->once()) + ->method('sendEvent') + ->willThrowException(new \Exception()) + ; + + $producer = new TraceableProducer($internalMessageProducer); + + $this->expectException(\Exception::class); + + try { + $producer->sendEvent('aFooTopic', 'aFooBody'); + } finally { + $this->assertEmpty($producer->getTraces()); + } + } + + public function testShouldPassAllArgumentsToInternalCommandMessageProducerSendMethod() + { + $command = 'theCommand'; + $body = 'theBody'; + + $internalMessageProducer = $this->createProducerMock(); + $internalMessageProducer + ->expects($this->once()) + ->method('sendCommand') + ->with($command, $body) + ; + + $producer = new TraceableProducer($internalMessageProducer); + + $producer->sendCommand($command, $body); + } + + public function testShouldCollectInfoIfStringGivenAsCommandMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendCommand('aFooCommand', 'aFooBody'); + + Assert::assertArraySubset([ + [ + 'topic' => null, + 'command' => 'aFooCommand', + 'body' => 'aFooBody', + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldCollectInfoIfArrayGivenAsCommandMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendCommand('aFooCommand', ['foo' => 'fooVal', 'bar' => 'barVal']); + + Assert::assertArraySubset([ + [ + 'topic' => null, + 'command' => 'aFooCommand', + 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'headers' => [], + 'properties' => [], + 'priority' => null, + 'expire' => null, + 'delay' => null, + 'timestamp' => null, + 'contentType' => null, + 'messageId' => null, + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldCollectInfoIfCommandMessageObjectGivenAsMessage() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $message = new Message(); + $message->setBody(['foo' => 'fooVal', 'bar' => 'barVal']); + $message->setProperty('fooProp', 'fooVal'); + $message->setHeader('fooHeader', 'fooVal'); + $message->setContentType('theContentType'); + $message->setDelay('theDelay'); + $message->setExpire('theExpire'); + $message->setMessageId('theMessageId'); + $message->setPriority('theMessagePriority'); + $message->setTimestamp('theTimestamp'); + + $producer->sendCommand('aFooCommand', $message); + + Assert::assertArraySubset([ + [ + 'topic' => null, + 'command' => 'aFooCommand', + 'body' => ['foo' => 'fooVal', 'bar' => 'barVal'], + 'headers' => ['fooHeader' => 'fooVal'], + 'properties' => ['fooProp' => 'fooVal'], + 'priority' => 'theMessagePriority', + 'expire' => 'theExpire', + 'delay' => 'theDelay', + 'timestamp' => 'theTimestamp', + 'contentType' => 'theContentType', + 'messageId' => 'theMessageId', + ], + ], $producer->getTraces()); + + $this->assertArrayHasKey('sentAt', $producer->getTraces()[0]); + } + + public function testShouldNotStoreAnythingIfInternalCommandMessageProducerThrowsException() + { + $internalMessageProducer = $this->createProducerMock(); + $internalMessageProducer + ->expects($this->once()) + ->method('sendCommand') + ->willThrowException(new \Exception()) + ; + + $producer = new TraceableProducer($internalMessageProducer); + + $this->expectException(\Exception::class); + + try { + $producer->sendCommand('aFooCommand', 'aFooBody'); + } finally { + $this->assertEmpty($producer->getTraces()); + } + } + + public function testShouldAllowGetInfoSentToSameTopic() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', 'aFooBody'); + $producer->sendEvent('aFooTopic', 'aFooBody'); + + Assert::assertArraySubset([ + ['topic' => 'aFooTopic', 'body' => 'aFooBody'], + ['topic' => 'aFooTopic', 'body' => 'aFooBody'], + ], $producer->getTraces()); + } + + public function testShouldAllowGetInfoSentToDifferentTopics() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', 'aFooBody'); + $producer->sendEvent('aBarTopic', 'aBarBody'); + + Assert::assertArraySubset([ + ['topic' => 'aFooTopic', 'body' => 'aFooBody'], + ['topic' => 'aBarTopic', 'body' => 'aBarBody'], + ], $producer->getTraces()); + } + + public function testShouldAllowGetInfoSentToSpecialTopic() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', 'aFooBody'); + $producer->sendEvent('aBarTopic', 'aBarBody'); + + Assert::assertArraySubset([ + ['topic' => 'aFooTopic', 'body' => 'aFooBody'], + ], $producer->getTopicTraces('aFooTopic')); + + Assert::assertArraySubset([ + ['topic' => 'aBarTopic', 'body' => 'aBarBody'], + ], $producer->getTopicTraces('aBarTopic')); + } + + public function testShouldAllowGetInfoSentToSameCommand() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendCommand('aFooCommand', 'aFooBody'); + $producer->sendCommand('aFooCommand', 'aFooBody'); + + Assert::assertArraySubset([ + ['command' => 'aFooCommand', 'body' => 'aFooBody'], + ['command' => 'aFooCommand', 'body' => 'aFooBody'], + ], $producer->getTraces()); + } + + public function testShouldAllowGetInfoSentToDifferentCommands() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendCommand('aFooCommand', 'aFooBody'); + $producer->sendCommand('aBarCommand', 'aBarBody'); + + Assert::assertArraySubset([ + ['command' => 'aFooCommand', 'body' => 'aFooBody'], + ['command' => 'aBarCommand', 'body' => 'aBarBody'], + ], $producer->getTraces()); + } + + public function testShouldAllowGetInfoSentToSpecialCommand() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendCommand('aFooCommand', 'aFooBody'); + $producer->sendCommand('aBarCommand', 'aBarBody'); + + Assert::assertArraySubset([ + ['command' => 'aFooCommand', 'body' => 'aFooBody'], + ], $producer->getCommandTraces('aFooCommand')); + + Assert::assertArraySubset([ + ['command' => 'aBarCommand', 'body' => 'aBarBody'], + ], $producer->getCommandTraces('aBarCommand')); + } + + public function testShouldAllowClearStoredTraces() + { + $producer = new TraceableProducer($this->createProducerMock()); + + $producer->sendEvent('aFooTopic', 'aFooBody'); + + // guard + $this->assertNotEmpty($producer->getTraces()); + + $producer->clearTraces(); + $this->assertSame([], $producer->getTraces()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerInterface + */ + protected function createProducerMock() + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/ConnectionFactoryFactoryTest.php b/pkg/enqueue/Tests/ConnectionFactoryFactoryTest.php new file mode 100644 index 000000000..b6b5b4d67 --- /dev/null +++ b/pkg/enqueue/Tests/ConnectionFactoryFactoryTest.php @@ -0,0 +1,183 @@ +assertTrue($rc->implementsInterface(ConnectionFactoryFactoryInterface::class)); + } + + public function testShouldBeFinal() + { + $rc = new \ReflectionClass(ConnectionFactoryFactory::class); + + $this->assertTrue($rc->isFinal()); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAcceptStringDSN() + { + $factory = new ConnectionFactoryFactory(); + + $factory->create('null:'); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAcceptArrayWithDsnKey() + { + $factory = new ConnectionFactoryFactory(); + + $factory->create(['dsn' => 'null:']); + } + + public function testThrowIfInvalidConfigGiven() + { + $factory = new ConnectionFactoryFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The config must be either array or DSN string.'); + $factory->create(new \stdClass()); + } + + public function testThrowIfArrayConfigMissDsnKeyInvalidConfigGiven() + { + $factory = new ConnectionFactoryFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The config must be either array or DSN string.'); + $factory->create(new \stdClass()); + } + + public function testThrowIfPackageThatSupportSchemeNotInstalled() + { + $scheme = 'scheme5b7aa7d7cd213'; + $class = 'ConnectionClass5b7aa7d7cd213'; + + Resources::addConnection($class, [$scheme], [], 'thePackage'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('To use given scheme "scheme5b7aa7d7cd213" a package has to be installed. Run "composer req thePackage" to add it.'); + (new ConnectionFactoryFactory())->create($scheme.'://foo'); + } + + public function testThrowIfSchemeIsNotKnown() + { + $scheme = 'scheme5b7aa862e70a5'; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A given scheme "scheme5b7aa862e70a5" is not supported. Maybe it is a custom connection, make sure you registered it with "Enqueue\Resources::addConnection".'); + (new ConnectionFactoryFactory())->create($scheme.'://foo'); + } + + public function testThrowIfDsnInvalid() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid. It does not have scheme separator ":".'); + + (new ConnectionFactoryFactory())->create('invalid-scheme'); + } + + /** + * @dataProvider provideDSN + */ + public function testReturnsExpectedFactories(string $dsn, string $expectedFactoryClass) + { + $connectionFactory = (new ConnectionFactoryFactory())->create($dsn); + + $this->assertInstanceOf($expectedFactoryClass, $connectionFactory); + } + + public static function provideDSN() + { + yield ['null:', NullConnectionFactory::class]; + + yield ['amqp:', AmqpBunnyConnectionFactory::class]; + + yield ['amqp+bunny:', AmqpBunnyConnectionFactory::class]; + + yield ['amqp+lib:', AmqpLibConnectionFactory::class]; + + yield ['amqp+ext:', AmqpExtConnectionFactory::class]; + + yield ['amqp+rabbitmq:', AmqpBunnyConnectionFactory::class]; + + yield ['amqp+rabbitmq+bunny:', AmqpBunnyConnectionFactory::class]; + + yield ['amqp+foo+bar+lib:', AmqpLibConnectionFactory::class]; + + yield ['amqp+rabbitmq+ext:', AmqpExtConnectionFactory::class]; + + yield ['amqp+rabbitmq+lib:', AmqpLibConnectionFactory::class]; + + // bunny does not support amqps, so it is skipped + yield ['amqps:', AmqpExtConnectionFactory::class]; + + // bunny does not support amqps, so it is skipped + yield ['amqps+ext:', AmqpExtConnectionFactory::class]; + + // bunny does not support amqps, so it is skipped + yield ['amqps+rabbitmq:', AmqpExtConnectionFactory::class]; + + yield ['amqps+ext+rabbitmq:', AmqpExtConnectionFactory::class]; + + yield ['amqps+lib+rabbitmq:', AmqpLibConnectionFactory::class]; + + yield ['mssql:', DbalConnectionFactory::class]; + + yield ['mysql:', DbalConnectionFactory::class]; + + yield ['pgsql:', DbalConnectionFactory::class]; + + yield ['file:', FsConnectionFactory::class]; + + // https://github.com/php-enqueue/enqueue-dev/issues/511 + // yield ['gearman:', GearmanConnectionFactory::class]; + + yield ['gps:', GpsConnectionFactory::class]; + + yield ['mongodb:', MongodbConnectionFactory::class]; + + yield ['beanstalk:', PheanstalkConnectionFactory::class]; + + yield ['kafka:', RdKafkaConnectionFactory::class]; + + yield ['redis:', RedisConnectionFactory::class]; + + yield ['redis+predis:', RedisConnectionFactory::class]; + + yield ['redis+foo+bar+phpredis:', RedisConnectionFactory::class]; + + yield ['redis+phpredis:', RedisConnectionFactory::class]; + + yield ['sqs:', SqsConnectionFactory::class]; + + yield ['stomp:', StompConnectionFactory::class]; + } +} diff --git a/pkg/enqueue/Tests/Consumption/CallbackProcessorTest.php b/pkg/enqueue/Tests/Consumption/CallbackProcessorTest.php index ac3e4992b..86adbd3a9 100644 --- a/pkg/enqueue/Tests/Consumption/CallbackProcessorTest.php +++ b/pkg/enqueue/Tests/Consumption/CallbackProcessorTest.php @@ -3,12 +3,13 @@ namespace Enqueue\Tests\Consumption; use Enqueue\Consumption\CallbackProcessor; -use Enqueue\Psr\Processor; +use Enqueue\Null\NullContext; +use Enqueue\Null\NullMessage; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullContext; -use Enqueue\Transport\Null\NullMessage; +use Interop\Queue\Processor; +use PHPUnit\Framework\TestCase; -class CallbackProcessorTest extends \PHPUnit_Framework_TestCase +class CallbackProcessorTest extends TestCase { use ClassExtensionTrait; @@ -17,12 +18,6 @@ public function testShouldImplementProcessorInterface() $this->assertClassImplements(Processor::class, CallbackProcessor::class); } - public function testCouldBeConstructedWithCallableAsArgument() - { - new CallbackProcessor(function () { - }); - } - public function testShouldCallCallbackAndProxyItsReturnedValue() { $expectedMessage = new NullMessage(); diff --git a/pkg/enqueue/Tests/Consumption/ChainExtensionTest.php b/pkg/enqueue/Tests/Consumption/ChainExtensionTest.php new file mode 100644 index 000000000..198d00012 --- /dev/null +++ b/pkg/enqueue/Tests/Consumption/ChainExtensionTest.php @@ -0,0 +1,320 @@ +assertClassImplements(ExtensionInterface::class, ChainExtension::class); + } + + public function testShouldProxyOnInitLoggerToAllInternalExtensions() + { + $context = new InitLogger(new NullLogger()); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onInitLogger') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onInitLogger') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onInitLogger($context); + } + + public function testShouldProxyOnStartToAllInternalExtensions() + { + $context = new Start($this->createInteropContextMock(), $this->createLoggerMock(), [], 0, 0); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onStart') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onStart') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onStart($context); + } + + public function testShouldProxyOnPreSubscribeToAllInternalExtensions() + { + $context = new PreSubscribe( + $this->createInteropContextMock(), + $this->createInteropProcessorMock(), + $this->createInteropConsumerMock(), + $this->createLoggerMock() + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPreSubscribe') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPreSubscribe') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPreSubscribe($context); + } + + public function testShouldProxyOnPreConsumeToAllInternalExtensions() + { + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + new NullLogger(), + 1, + 2, + 3 + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPreConsume') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPreConsume') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + $extensions->onPreConsume($context); + } + + public function testShouldProxyOnPreReceiveToAllInternalExtensions() + { + $context = new MessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + $this->createMock(Processor::class), + 1, + new NullLogger() + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onMessageReceived') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onMessageReceived') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onMessageReceived($context); + } + + public function testShouldProxyOnResultToAllInternalExtensions() + { + $context = new MessageResult( + $this->createInteropContextMock(), + $this->createInteropConsumerMock(), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onResult') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onResult') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onResult($context); + } + + public function testShouldProxyOnPostReceiveToAllInternalExtensions() + { + $context = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPostMessageReceived') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPostMessageReceived') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPostMessageReceived($context); + } + + public function testShouldProxyOnPostConsumeToAllInternalExtensions() + { + $postConsume = new PostConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + 1, + 1, + 1, + new NullLogger() + ); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onPostConsume') + ->with($this->identicalTo($postConsume)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onPostConsume') + ->with($this->identicalTo($postConsume)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onPostConsume($postConsume); + } + + public function testShouldProxyOnEndToAllInternalExtensions() + { + $context = new End($this->createInteropContextMock(), 1, 2, new NullLogger()); + + $fooExtension = $this->createExtension(); + $fooExtension + ->expects($this->once()) + ->method('onEnd') + ->with($this->identicalTo($context)) + ; + $barExtension = $this->createExtension(); + $barExtension + ->expects($this->once()) + ->method('onEnd') + ->with($this->identicalTo($context)) + ; + + $extensions = new ChainExtension([$fooExtension, $barExtension]); + + $extensions->onEnd($context); + } + + /** + * @return MockObject + */ + protected function createLoggerMock(): LoggerInterface + { + return $this->createMock(LoggerInterface::class); + } + + /** + * @return MockObject + */ + protected function createInteropContextMock(): Context + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject + */ + protected function createInteropConsumerMock(): Consumer + { + return $this->createMock(Consumer::class); + } + + /** + * @return MockObject + */ + protected function createInteropProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return MockObject|ExtensionInterface + */ + protected function createExtension() + { + return $this->createMock(ExtensionInterface::class); + } + + /** + * @return MockObject + */ + private function createSubscriptionConsumerMock(): SubscriptionConsumer + { + return $this->createMock(SubscriptionConsumer::class); + } +} diff --git a/pkg/enqueue/Tests/Consumption/ContextTest.php b/pkg/enqueue/Tests/Consumption/ContextTest.php deleted file mode 100644 index 3d891e232..000000000 --- a/pkg/enqueue/Tests/Consumption/ContextTest.php +++ /dev/null @@ -1,257 +0,0 @@ -createPsrContext()); - } - - public function testShouldAllowGetSessionSetInConstructor() - { - $psrContext = $this->createPsrContext(); - - $context = new Context($psrContext); - - $this->assertSame($psrContext, $context->getPsrContext()); - } - - public function testShouldAllowGetMessageConsumerPreviouslySet() - { - $messageConsumer = $this->createPsrConsumer(); - - $context = new Context($this->createPsrContext()); - $context->setPsrConsumer($messageConsumer); - - $this->assertSame($messageConsumer, $context->getPsrConsumer()); - } - - public function testThrowOnTryToChangeMessageConsumerIfAlreadySet() - { - $messageConsumer = $this->createPsrConsumer(); - $anotherMessageConsumer = $this->createPsrConsumer(); - - $context = new Context($this->createPsrContext()); - - $context->setPsrConsumer($messageConsumer); - - $this->expectException(IllegalContextModificationException::class); - - $context->setPsrConsumer($anotherMessageConsumer); - } - - public function testShouldAllowGetMessageProducerPreviouslySet() - { - $processorMock = $this->createProcessorMock(); - - $context = new Context($this->createPsrContext()); - $context->setPsrProcessor($processorMock); - - $this->assertSame($processorMock, $context->getPsrProcessor()); - } - - public function testThrowOnTryToChangeProcessorIfAlreadySet() - { - $processor = $this->createProcessorMock(); - $anotherProcessor = $this->createProcessorMock(); - - $context = new Context($this->createPsrContext()); - - $context->setPsrProcessor($processor); - - $this->expectException(IllegalContextModificationException::class); - - $context->setPsrProcessor($anotherProcessor); - } - - public function testShouldAllowGetLoggerPreviouslySet() - { - $logger = new NullLogger(); - - $context = new Context($this->createPsrContext()); - $context->setLogger($logger); - - $this->assertSame($logger, $context->getLogger()); - } - - public function testShouldSetExecutionInterruptedToFalseInConstructor() - { - $context = new Context($this->createPsrContext()); - - $this->assertFalse($context->isExecutionInterrupted()); - } - - public function testShouldAllowGetPreviouslySetMessage() - { - /** @var Message $message */ - $message = $this->createMock(Message::class); - - $context = new Context($this->createPsrContext()); - - $context->setPsrMessage($message); - - $this->assertSame($message, $context->getPsrMessage()); - } - - public function testThrowOnTryToChangeMessageIfAlreadySet() - { - /** @var Message $message */ - $message = $this->createMock(Message::class); - - $context = new Context($this->createPsrContext()); - - $this->expectException(IllegalContextModificationException::class); - $this->expectExceptionMessage('The message could be set once'); - - $context->setPsrMessage($message); - $context->setPsrMessage($message); - } - - public function testShouldAllowGetPreviouslySetException() - { - $exception = new \Exception(); - - $context = new Context($this->createPsrContext()); - - $context->setException($exception); - - $this->assertSame($exception, $context->getException()); - } - - public function testShouldAllowGetPreviouslySetResult() - { - $result = 'aResult'; - - $context = new Context($this->createPsrContext()); - - $context->setResult($result); - - $this->assertSame($result, $context->getResult()); - } - - public function testThrowOnTryToChangeResultIfAlreadySet() - { - $result = 'aResult'; - - $context = new Context($this->createPsrContext()); - - $this->expectException(IllegalContextModificationException::class); - $this->expectExceptionMessage('The result modification is not allowed'); - - $context->setResult($result); - $context->setResult($result); - } - - public function testShouldAllowGetPreviouslySetExecutionInterrupted() - { - $context = new Context($this->createPsrContext()); - - // guard - $this->assertFalse($context->isExecutionInterrupted()); - - $context->setExecutionInterrupted(true); - - $this->assertTrue($context->isExecutionInterrupted()); - } - - public function testThrowOnTryToRollbackExecutionInterruptedIfAlreadySetToTrue() - { - $context = new Context($this->createPsrContext()); - - $this->expectException(IllegalContextModificationException::class); - $this->expectExceptionMessage('The execution once interrupted could not be roll backed'); - - $context->setExecutionInterrupted(true); - $context->setExecutionInterrupted(false); - } - - public function testNotThrowOnSettingExecutionInterruptedToTrueIfAlreadySetToTrue() - { - $context = new Context($this->createPsrContext()); - - $context->setExecutionInterrupted(true); - $context->setExecutionInterrupted(true); - } - - public function testShouldAllowGetPreviouslySetLogger() - { - $expectedLogger = new NullLogger(); - - $context = new Context($this->createPsrContext()); - - $context->setLogger($expectedLogger); - - $this->assertSame($expectedLogger, $context->getLogger()); - } - - public function testThrowOnSettingLoggerIfAlreadySet() - { - $context = new Context($this->createPsrContext()); - - $context->setLogger(new NullLogger()); - - $this->expectException(IllegalContextModificationException::class); - $this->expectExceptionMessage('The logger modification is not allowed'); - - $context->setLogger(new NullLogger()); - } - - public function testShouldAllowGetPreviouslySetQueue() - { - $context = new Context($this->createPsrContext()); - - $context->setPsrQueue($queue = new NullQueue('')); - - $this->assertSame($queue, $context->getPsrQueue()); - } - - public function testThrowOnSettingQueueNameIfAlreadySet() - { - $context = new Context($this->createPsrContext()); - - $context->setPsrQueue(new NullQueue('')); - - $this->expectException(IllegalContextModificationException::class); - $this->expectExceptionMessage('The queue modification is not allowed'); - - $context->setPsrQueue(new NullQueue('')); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|PsrContext - */ - protected function createPsrContext() - { - return $this->createMock(PsrContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Consumer - */ - protected function createPsrConsumer() - { - return $this->createMock(Consumer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor - */ - protected function createProcessorMock() - { - return $this->createMock(Processor::class); - } -} diff --git a/pkg/enqueue/Tests/Consumption/Exception/ConsumptionInterruptedExceptionTest.php b/pkg/enqueue/Tests/Consumption/Exception/ConsumptionInterruptedExceptionTest.php deleted file mode 100644 index f4fa0678d..000000000 --- a/pkg/enqueue/Tests/Consumption/Exception/ConsumptionInterruptedExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertClassImplements(ExceptionInterface::class, ConsumptionInterruptedException::class); - } - - public function testShouldExtendLogicException() - { - $this->assertClassExtends(\LogicException::class, ConsumptionInterruptedException::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new ConsumptionInterruptedException(); - } -} diff --git a/pkg/enqueue/Tests/Consumption/Exception/IllegalContextModificationExceptionTest.php b/pkg/enqueue/Tests/Consumption/Exception/IllegalContextModificationExceptionTest.php index 436e594d7..241f4adf9 100644 --- a/pkg/enqueue/Tests/Consumption/Exception/IllegalContextModificationExceptionTest.php +++ b/pkg/enqueue/Tests/Consumption/Exception/IllegalContextModificationExceptionTest.php @@ -1,12 +1,13 @@ assertClassExtends(\LogicException::class, IllegalContextModificationException::class); } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new IllegalContextModificationException(); - } } diff --git a/pkg/enqueue/Tests/Consumption/Exception/InvalidArgumentExceptionTest.php b/pkg/enqueue/Tests/Consumption/Exception/InvalidArgumentExceptionTest.php index 16c3b3c6f..c1c5db362 100644 --- a/pkg/enqueue/Tests/Consumption/Exception/InvalidArgumentExceptionTest.php +++ b/pkg/enqueue/Tests/Consumption/Exception/InvalidArgumentExceptionTest.php @@ -1,12 +1,13 @@ assertClassExtends(\LogicException::class, InvalidArgumentException::class); } - public function testCouldBeConstructedWithoutAnyArguments() - { - new InvalidArgumentException(); - } - public function testThrowIfAssertInstanceOfNotSameAsExpected() { $this->expectException(InvalidArgumentException::class); @@ -35,6 +31,9 @@ public function testThrowIfAssertInstanceOfNotSameAsExpected() InvalidArgumentException::assertInstanceOf(new \SplStack(), \SplQueue::class); } + /** + * @doesNotPerformAssertions + */ public function testShouldDoNothingIfAssertDestinationInstanceOfSameAsExpected() { InvalidArgumentException::assertInstanceOf(new \SplQueue(), \SplQueue::class); diff --git a/pkg/enqueue/Tests/Consumption/Exception/LogicExceptionTest.php b/pkg/enqueue/Tests/Consumption/Exception/LogicExceptionTest.php index d81975d3c..2655609ae 100644 --- a/pkg/enqueue/Tests/Consumption/Exception/LogicExceptionTest.php +++ b/pkg/enqueue/Tests/Consumption/Exception/LogicExceptionTest.php @@ -1,12 +1,13 @@ assertClassExtends(\LogicException::class, LogicException::class); } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new LogicException(); - } } diff --git a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumedMessagesExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumedMessagesExtensionTest.php index e9b783737..137e30ba4 100644 --- a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumedMessagesExtensionTest.php +++ b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumedMessagesExtensionTest.php @@ -2,40 +2,84 @@ namespace Enqueue\Tests\Consumption\Extension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; use Enqueue\Consumption\Extension\LimitConsumedMessagesExtension; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Processor; +use Interop\Queue\Consumer; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\SubscriptionConsumer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class LimitConsumedMessagesExtensionTest extends \PHPUnit_Framework_TestCase +class LimitConsumedMessagesExtensionTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() + public function testOnPreConsumeShouldInterruptWhenLimitIsReached() { - new LimitConsumedMessagesExtension(12345); - } + $logger = $this->createLoggerMock(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('[LimitConsumedMessagesExtension] Message consumption is interrupted since'. + ' the message limit reached. limit: "3"') + ; - public function testShouldThrowExceptionIfMessageLimitIsNotInt() - { - $this->setExpectedException( - \InvalidArgumentException::class, - 'Expected message limit is int but got: "double"' + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + $logger, + 1, + 2, + 3 + ); + + // guard + $this->assertFalse($context->isExecutionInterrupted()); + + // test + $extension = new LimitConsumedMessagesExtension(3); + + $extension->onPreConsume($context); + $this->assertFalse($context->isExecutionInterrupted()); + + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() ); - new LimitConsumedMessagesExtension(0.0); + $extension->onPostMessageReceived($postReceivedMessage); + $extension->onPostMessageReceived($postReceivedMessage); + $extension->onPostMessageReceived($postReceivedMessage); + + $extension->onPreConsume($context); + $this->assertTrue($context->isExecutionInterrupted()); } - public function testOnBeforeReceiveShouldInterruptExecutionIfLimitIsZero() + public function testOnPreConsumeShouldInterruptExecutionIfLimitIsZero() { - $context = $this->createContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with('[LimitConsumedMessagesExtension] Message consumption is interrupted since'. ' the message limit reached. limit: "0"') ; + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + $logger, + 1, + 2, + 3 + ); + // guard $this->assertFalse($context->isExecutionInterrupted()); @@ -43,20 +87,29 @@ public function testOnBeforeReceiveShouldInterruptExecutionIfLimitIsZero() $extension = new LimitConsumedMessagesExtension(0); // consume 1 - $extension->onBeforeReceive($context); + $extension->onPreConsume($context); $this->assertTrue($context->isExecutionInterrupted()); } - public function testOnBeforeReceiveShouldInterruptExecutionIfLimitIsLessThatZero() + public function testOnPreConsumeShouldInterruptExecutionIfLimitIsLessThatZero() { - $context = $this->createContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with('[LimitConsumedMessagesExtension] Message consumption is interrupted since'. ' the message limit reached. limit: "-1"') ; + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + $logger, + 1, + 2, + 3 + ); + // guard $this->assertFalse($context->isExecutionInterrupted()); @@ -64,45 +117,65 @@ public function testOnBeforeReceiveShouldInterruptExecutionIfLimitIsLessThatZero $extension = new LimitConsumedMessagesExtension(-1); // consume 1 - $extension->onBeforeReceive($context); + $extension->onPreConsume($context); $this->assertTrue($context->isExecutionInterrupted()); } public function testOnPostReceivedShouldInterruptExecutionIfMessageLimitExceeded() { - $context = $this->createContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with('[LimitConsumedMessagesExtension] Message consumption is interrupted since'. ' the message limit reached. limit: "2"') ; + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + $logger + ); + // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // test $extension = new LimitConsumedMessagesExtension(2); // consume 1 - $extension->onPostReceived($context); - $this->assertFalse($context->isExecutionInterrupted()); + $extension->onPostMessageReceived($postReceivedMessage); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // consume 2 and exit - $extension->onPostReceived($context); - $this->assertTrue($context->isExecutionInterrupted()); + $extension->onPostMessageReceived($postReceivedMessage); + $this->assertTrue($postReceivedMessage->isExecutionInterrupted()); } /** - * @return Context + * @return MockObject */ - protected function createContext() + protected function createInteropContextMock(): Context { - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($this->createMock(LoggerInterface::class)); - $context->setPsrConsumer($this->createMock(Consumer::class)); - $context->setPsrProcessor($this->createMock(Processor::class)); + return $this->createMock(Context::class); + } - return $context; + /** + * @return MockObject + */ + private function createSubscriptionConsumerMock(): SubscriptionConsumer + { + return $this->createMock(SubscriptionConsumer::class); + } + + /** + * @return MockObject + */ + private function createLoggerMock(): LoggerInterface + { + return $this->createMock(LoggerInterface::class); } } diff --git a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumerMemoryExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumerMemoryExtensionTest.php index c7f42a207..25ac85895 100644 --- a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumerMemoryExtensionTest.php +++ b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumerMemoryExtensionTest.php @@ -2,136 +2,197 @@ namespace Enqueue\Tests\Consumption\Extension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; use Enqueue\Consumption\Extension\LimitConsumerMemoryExtension; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Processor; +use Interop\Queue\Consumer; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\SubscriptionConsumer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class LimitConsumerMemoryExtensionTest extends \PHPUnit_Framework_TestCase +class LimitConsumerMemoryExtensionTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new LimitConsumerMemoryExtension(12345); - } - public function testShouldThrowExceptionIfMemoryLimitIsNotInt() { - $this->setExpectedException(\InvalidArgumentException::class, 'Expected memory limit is int but got: "double"'); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Expected memory limit is int but got: "double"'); new LimitConsumerMemoryExtension(0.0); } - public function testOnIdleShouldInterruptExecutionIfMemoryLimitReached() + public function testOnPostConsumeShouldInterruptExecutionIfMemoryLimitReached() { - $context = $this->createPsrContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with($this->stringContains('[LimitConsumerMemoryExtension] Interrupt execution as memory limit reached.')) ; + $postConsume = new PostConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + 1, + 1, + 1, + $logger + ); + // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); // test $extension = new LimitConsumerMemoryExtension(1); - $extension->onIdle($context); + $extension->onPostConsume($postConsume); - $this->assertTrue($context->isExecutionInterrupted()); + $this->assertTrue($postConsume->isExecutionInterrupted()); } public function testOnPostReceivedShouldInterruptExecutionIfMemoryLimitReached() { - $context = $this->createPsrContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with($this->stringContains('[LimitConsumerMemoryExtension] Interrupt execution as memory limit reached.')) ; + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + $logger + ); + // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // test $extension = new LimitConsumerMemoryExtension(1); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); - $this->assertTrue($context->isExecutionInterrupted()); + $this->assertTrue($postReceivedMessage->isExecutionInterrupted()); } - public function testOnBeforeReceivedShouldInterruptExecutionIfMemoryLimitReached() + public function testOnPreConsumeShouldInterruptExecutionIfMemoryLimitReached() { - $context = $this->createPsrContext(); - $context->getLogger() + $logger = $this->createLoggerMock(); + $logger ->expects($this->once()) ->method('debug') ->with($this->stringContains('[LimitConsumerMemoryExtension] Interrupt execution as memory limit reached.')) ; + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + $logger, + 1, + 2, + 3 + ); + // guard $this->assertFalse($context->isExecutionInterrupted()); // test $extension = new LimitConsumerMemoryExtension(1); - $extension->onBeforeReceive($context); + $extension->onPreConsume($context); $this->assertTrue($context->isExecutionInterrupted()); } - public function testOnBeforeReceiveShouldNotInterruptExecutionIfMemoryLimitIsNotReached() + public function testOnPreConsumeShouldNotInterruptExecutionIfMemoryLimitIsNotReached() { - $context = $this->createPsrContext(); + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + new NullLogger(), + 1, + 2, + 3 + ); // guard $this->assertFalse($context->isExecutionInterrupted()); // test - $extension = new LimitConsumerMemoryExtension(PHP_INT_MAX); - $extension->onBeforeReceive($context); + $extension = new LimitConsumerMemoryExtension(\PHP_INT_MAX); + $extension->onPreConsume($context); $this->assertFalse($context->isExecutionInterrupted()); } - public function testOnIdleShouldNotInterruptExecutionIfMemoryLimitIsNotReached() + public function testOnPostConsumeShouldNotInterruptExecutionIfMemoryLimitIsNotReached() { - $context = $this->createPsrContext(); + $postConsume = new PostConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + 1, + 1, + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); // test - $extension = new LimitConsumerMemoryExtension(PHP_INT_MAX); - $extension->onIdle($context); + $extension = new LimitConsumerMemoryExtension(\PHP_INT_MAX); + $extension->onPostConsume($postConsume); - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); } - public function testOnPostReceivedShouldNotInterruptExecutionIfMemoryLimitIsNotReached() + public function testOnPostMessageReceivedShouldNotInterruptExecutionIfMemoryLimitIsNotReached() { - $context = $this->createPsrContext(); + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // test - $extension = new LimitConsumerMemoryExtension(PHP_INT_MAX); - $extension->onPostReceived($context); + $extension = new LimitConsumerMemoryExtension(\PHP_INT_MAX); + $extension->onPostMessageReceived($postReceivedMessage); - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); } /** - * @return Context + * @return MockObject */ - protected function createPsrContext() + protected function createInteropContextMock(): Context { - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($this->createMock(LoggerInterface::class)); - $context->setPsrConsumer($this->createMock(Consumer::class)); - $context->setPsrProcessor($this->createMock(Processor::class)); + return $this->createMock(Context::class); + } - return $context; + /** + * @return MockObject + */ + private function createSubscriptionConsumerMock(): SubscriptionConsumer + { + return $this->createMock(SubscriptionConsumer::class); + } + + /** + * @return MockObject + */ + private function createLoggerMock(): LoggerInterface + { + return $this->createMock(LoggerInterface::class); } } diff --git a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumptionTimeExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumptionTimeExtensionTest.php index 2abb75ecd..fa6cb76a1 100644 --- a/pkg/enqueue/Tests/Consumption/Extension/LimitConsumptionTimeExtensionTest.php +++ b/pkg/enqueue/Tests/Consumption/Extension/LimitConsumptionTimeExtensionTest.php @@ -2,23 +2,31 @@ namespace Enqueue\Tests\Consumption\Extension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; use Enqueue\Consumption\Extension\LimitConsumptionTimeExtension; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Processor; +use Interop\Queue\Consumer; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\SubscriptionConsumer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class LimitConsumptionTimeExtensionTest extends \PHPUnit_Framework_TestCase +class LimitConsumptionTimeExtensionTest extends TestCase { - public function testCouldBeConstructedWithRequiredArguments() + public function testOnPreConsumeShouldInterruptExecutionIfConsumptionTimeExceeded() { - new LimitConsumptionTimeExtension(new \DateTime('+1 day')); - } - - public function testOnBeforeReceiveShouldInterruptExecutionIfConsumptionTimeExceeded() - { - $context = $this->createContext(); + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + new NullLogger(), + 1, + 2, + 3 + ); // guard $this->assertFalse($context->isExecutionInterrupted()); @@ -26,44 +34,65 @@ public function testOnBeforeReceiveShouldInterruptExecutionIfConsumptionTimeExce // test $extension = new LimitConsumptionTimeExtension(new \DateTime('-2 second')); - $extension->onBeforeReceive($context); + $extension->onPreConsume($context); $this->assertTrue($context->isExecutionInterrupted()); } - public function testOnIdleShouldInterruptExecutionIfConsumptionTimeExceeded() + public function testOnPostConsumeShouldInterruptExecutionIfConsumptionTimeExceeded() { - $context = $this->createContext(); + $postConsume = new PostConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + 1, + 1, + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); // test $extension = new LimitConsumptionTimeExtension(new \DateTime('-2 second')); - $extension->onIdle($context); + $extension->onPostConsume($postConsume); - $this->assertTrue($context->isExecutionInterrupted()); + $this->assertTrue($postConsume->isExecutionInterrupted()); } public function testOnPostReceivedShouldInterruptExecutionIfConsumptionTimeExceeded() { - $context = $this->createContext(); + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // test $extension = new LimitConsumptionTimeExtension(new \DateTime('-2 second')); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); - $this->assertTrue($context->isExecutionInterrupted()); + $this->assertTrue($postReceivedMessage->isExecutionInterrupted()); } - public function testOnBeforeReceiveShouldNotInterruptExecutionIfConsumptionTimeIsNotExceeded() + public function testOnPreConsumeShouldNotInterruptExecutionIfConsumptionTimeIsNotExceeded() { - $context = $this->createContext(); + $context = new PreConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + new NullLogger(), + 1, + 2, + 3 + ); // guard $this->assertFalse($context->isExecutionInterrupted()); @@ -71,51 +100,76 @@ public function testOnBeforeReceiveShouldNotInterruptExecutionIfConsumptionTimeI // test $extension = new LimitConsumptionTimeExtension(new \DateTime('+2 second')); - $extension->onBeforeReceive($context); + $extension->onPreConsume($context); $this->assertFalse($context->isExecutionInterrupted()); } - public function testOnIdleShouldNotInterruptExecutionIfConsumptionTimeIsNotExceeded() + public function testOnPostConsumeShouldNotInterruptExecutionIfConsumptionTimeIsNotExceeded() { - $context = $this->createContext(); + $postConsume = new PostConsume( + $this->createInteropContextMock(), + $this->createSubscriptionConsumerMock(), + 1, + 1, + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); // test $extension = new LimitConsumptionTimeExtension(new \DateTime('+2 second')); - $extension->onIdle($context); + $extension->onPostConsume($postConsume); - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postConsume->isExecutionInterrupted()); } public function testOnPostReceivedShouldNotInterruptExecutionIfConsumptionTimeIsNotExceeded() { - $context = $this->createContext(); + $postReceivedMessage = new PostMessageReceived( + $this->createInteropContextMock(), + $this->createMock(Consumer::class), + $this->createMock(Message::class), + 'aResult', + 1, + new NullLogger() + ); // guard - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); // test $extension = new LimitConsumptionTimeExtension(new \DateTime('+2 second')); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); - $this->assertFalse($context->isExecutionInterrupted()); + $this->assertFalse($postReceivedMessage->isExecutionInterrupted()); + } + + /** + * @return MockObject + */ + protected function createInteropContextMock(): Context + { + return $this->createMock(Context::class); } /** - * @return Context + * @return MockObject */ - protected function createContext() + private function createSubscriptionConsumerMock(): SubscriptionConsumer { - $context = new Context($this->createMock(PsrContext::class)); - $context->setLogger($this->createMock(LoggerInterface::class)); - $context->setPsrConsumer($this->createMock(Consumer::class)); - $context->setPsrProcessor($this->createMock(Processor::class)); + return $this->createMock(SubscriptionConsumer::class); + } - return $context; + /** + * @return MockObject + */ + private function createLoggerMock(): LoggerInterface + { + return $this->createMock(LoggerInterface::class); } } diff --git a/pkg/enqueue/Tests/Consumption/Extension/LogExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/LogExtensionTest.php new file mode 100644 index 000000000..006a2c549 --- /dev/null +++ b/pkg/enqueue/Tests/Consumption/Extension/LogExtensionTest.php @@ -0,0 +1,266 @@ +assertClassImplements(StartExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementEndExtensionInterface() + { + $this->assertClassImplements(EndExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementMessageReceivedExtensionInterface() + { + $this->assertClassImplements(MessageReceivedExtensionInterface::class, LogExtension::class); + } + + public function testShouldImplementPostMessageReceivedExtensionInterface() + { + $this->assertClassImplements(PostMessageReceivedExtensionInterface::class, LogExtension::class); + } + + public function testShouldLogStartOnStart() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Consumption has started') + ; + + $context = new Start($this->createContextMock(), $logger, [], 1, 1); + + $extension = new LogExtension(); + $extension->onStart($context); + } + + public function testShouldLogEndOnEnd() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Consumption has ended') + ; + + $context = new End($this->createContextMock(), 1, 2, $logger); + + $extension = new LogExtension(); + $extension->onEnd($context); + } + + public function testShouldLogMessageReceived() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('debug') + ->with('Received from {queueName} {body}', [ + 'queueName' => 'aQueue', + 'redelivered' => false, + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + ]) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new MessageReceived($this->createContextMock(), $consumerMock, $message, $this->createProcessorMock(), 1, $logger); + + $extension = new LogExtension(); + $extension->onMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithStringResult() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'aResult', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, 'aResult', 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogRejectedMessageAsError() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'reject', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Processor::REJECT, 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => '', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack(), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + public function testShouldLogMessageProcessedWithReasonResultObject() + { + $logger = $this->createLogger(); + $logger + ->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, + 'Processed from {queueName} {body} {result} {reason}', + [ + 'queueName' => 'aQueue', + 'body' => Stringify::that('aBody'), + 'properties' => Stringify::that(['aProp' => 'aPropVal']), + 'headers' => Stringify::that(['aHeader' => 'aHeaderVal']), + 'result' => 'ack', + 'reason' => 'aReason', + ] + ) + ; + + $consumerMock = $this->createConsumerStub(new NullQueue('aQueue')); + $message = new NullMessage('aBody'); + $message->setProperty('aProp', 'aPropVal'); + $message->setHeader('aHeader', 'aHeaderVal'); + + $context = new PostMessageReceived($this->createContextMock(), $consumerMock, $message, Result::ack('aReason'), 1, $logger); + + $extension = new LogExtension(); + $extension->onPostMessageReceived($context); + } + + /** + * @return MockObject + */ + private function createConsumerStub(Queue $queue): Consumer + { + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue) + ; + + return $consumerMock; + } + + /** + * @return MockObject + */ + private function createContextMock(): Context + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject + */ + private function createProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return MockObject|LoggerInterface + */ + private function createLogger() + { + return $this->createMock(LoggerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Consumption/Extension/LoggerExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/LoggerExtensionTest.php index 62998e82d..666892e0e 100644 --- a/pkg/enqueue/Tests/Consumption/Extension/LoggerExtensionTest.php +++ b/pkg/enqueue/Tests/Consumption/Extension/LoggerExtensionTest.php @@ -2,175 +2,78 @@ namespace Enqueue\Tests\Consumption\Extension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\InitLogger; use Enqueue\Consumption\Extension\LoggerExtension; -use Enqueue\Consumption\ExtensionInterface; -use Enqueue\Consumption\Result; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; +use Enqueue\Consumption\InitLoggerExtensionInterface; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullMessage; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class LoggerExtensionTest extends \PHPUnit_Framework_TestCase +class LoggerExtensionTest extends TestCase { use ClassExtensionTrait; - public function testShouldImplementExtensionInterface() + public function testShouldImplementInitLoggerExtensionInterface() { - $this->assertClassImplements(ExtensionInterface::class, LoggerExtension::class); + $this->assertClassImplements(InitLoggerExtensionInterface::class, LoggerExtension::class); } - public function testCouldBeConstructedWithLoggerAsFirstArgument() - { - new LoggerExtension($this->createLogger()); - } - - public function testShouldSetLoggerToContextOnStart() + public function testShouldSetLoggerToContextOnInitLogger() { $logger = $this->createLogger(); $extension = new LoggerExtension($logger); - $context = new Context($this->createPsrContextMock()); + $previousLogger = new NullLogger(); + $context = new InitLogger($previousLogger); - $extension->onStart($context); + $extension->onInitLogger($context); $this->assertSame($logger, $context->getLogger()); } public function testShouldAddInfoMessageOnStart() { - $logger = $this->createLogger(); - $logger - ->expects($this->once()) - ->method('debug') - ->with($this->stringStartsWith('Set context\'s logger')) - ; - - $extension = new LoggerExtension($logger); - - $context = new Context($this->createPsrContextMock()); - - $extension->onStart($context); - } - - public function testShouldLogRejectMessageStatus() - { - $logger = $this->createLogger(); - $logger - ->expects($this->once()) - ->method('error') - ->with('reason', ['body' => 'message body', 'headers' => [], 'properties' => []]) - ; - - $extension = new LoggerExtension($logger); - - $message = new NullMessage(); - $message->setBody('message body'); - - $context = new Context($this->createPsrContextMock()); - $context->setResult(Result::reject('reason')); - $context->setPsrMessage($message); - - $extension->onPostReceived($context); - } - - public function testShouldLogRequeueMessageStatus() - { - $logger = $this->createLogger(); - $logger - ->expects($this->once()) - ->method('error') - ->with('reason', ['body' => 'message body', 'headers' => [], 'properties' => []]) - ; - - $extension = new LoggerExtension($logger); - - $message = new NullMessage(); - $message->setBody('message body'); - - $context = new Context($this->createPsrContextMock()); - $context->setResult(Result::requeue('reason')); - $context->setPsrMessage($message); - - $extension->onPostReceived($context); - } - - public function testShouldNotLogRequeueMessageStatusIfReasonIsEmpty() - { - $logger = $this->createLogger(); - $logger - ->expects($this->never()) - ->method('error') - ; + $previousLogger = $this->createLogger(); - $extension = new LoggerExtension($logger); - - $context = new Context($this->createPsrContextMock()); - $context->setResult(Result::requeue()); - - $extension->onPostReceived($context); - } - - public function testShouldLogAckMessageStatus() - { $logger = $this->createLogger(); $logger ->expects($this->once()) - ->method('info') - ->with('reason', ['body' => 'message body', 'headers' => [], 'properties' => []]) + ->method('debug') + ->with(sprintf('Change logger from "%s" to "%s"', $logger::class, $previousLogger::class)) ; $extension = new LoggerExtension($logger); - $message = new NullMessage(); - $message->setBody('message body'); - - $context = new Context($this->createPsrContextMock()); - $context->setResult(Result::ack('reason')); - $context->setPsrMessage($message); + $context = new InitLogger($previousLogger); - $extension->onPostReceived($context); + $extension->onInitLogger($context); } - public function testShouldNotLogAckMessageStatusIfReasonIsEmpty() + public function testShouldDoNothingIfSameLoggerInstanceAlreadySet() { $logger = $this->createLogger(); $logger ->expects($this->never()) - ->method('info') + ->method('debug') ; $extension = new LoggerExtension($logger); - $context = new Context($this->createPsrContextMock()); - $context->setResult(Result::ack()); + $context = new InitLogger($logger); - $extension->onPostReceived($context); - } + $extension->onInitLogger($context); - /** - * @return \PHPUnit_Framework_MockObject_MockObject|PsrContext - */ - protected function createPsrContextMock() - { - return $this->createMock(PsrContext::class); + $this->assertSame($logger, $context->getLogger()); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface + * @return MockObject|LoggerInterface */ protected function createLogger() { return $this->createMock(LoggerInterface::class); } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Consumer - */ - protected function createConsumerMock() - { - return $this->createMock(Consumer::class); - } } diff --git a/pkg/enqueue/Tests/Consumption/Extension/NicenessExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/NicenessExtensionTest.php new file mode 100644 index 000000000..734bc8417 --- /dev/null +++ b/pkg/enqueue/Tests/Consumption/Extension/NicenessExtensionTest.php @@ -0,0 +1,38 @@ +expectException(\InvalidArgumentException::class); + new NicenessExtension('1'); + } + + public function testShouldThrowWarningOnInvalidArgument() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('proc_nice(): Only a super user may attempt to increase the priority of a process'); + + $context = new Start($this->createContextMock(), new NullLogger(), [], 0, 0); + + $extension = new NicenessExtension(-1); + $extension->onStart($context); + } + + /** + * @return MockObject|InteropContext + */ + protected function createContextMock(): InteropContext + { + return $this->createMock(InteropContext::class); + } +} diff --git a/pkg/enqueue/Tests/Consumption/Extension/ReplyExtensionTest.php b/pkg/enqueue/Tests/Consumption/Extension/ReplyExtensionTest.php index 9e23b426d..cb65816ce 100644 --- a/pkg/enqueue/Tests/Consumption/Extension/ReplyExtensionTest.php +++ b/pkg/enqueue/Tests/Consumption/Extension/ReplyExtensionTest.php @@ -2,99 +2,81 @@ namespace Enqueue\Tests\Consumption\Extension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\PostMessageReceived; use Enqueue\Consumption\Extension\ReplyExtension; -use Enqueue\Consumption\ExtensionInterface; +use Enqueue\Consumption\PostMessageReceivedExtensionInterface; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Producer; +use Enqueue\Null\NullMessage; +use Enqueue\Null\NullQueue; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullContext; -use Enqueue\Transport\Null\NullMessage; -use Enqueue\Transport\Null\NullQueue; - -class ReplyExtensionTest extends \PHPUnit_Framework_TestCase +use Interop\Queue\Consumer; +use Interop\Queue\Context; +use Interop\Queue\Producer as InteropProducer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class ReplyExtensionTest extends TestCase { use ClassExtensionTrait; - public function testShouldImplementExtensionInterface() - { - $this->assertClassImplements(ExtensionInterface::class, ReplyExtension::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new ReplyExtension(); - } - - public function testShouldDoNothingOnPreReceived() - { - $extension = new ReplyExtension(); - - $extension->onPreReceived(new Context(new NullContext())); - } - - public function testShouldDoNothingOnStart() - { - $extension = new ReplyExtension(); - - $extension->onStart(new Context(new NullContext())); - } - - public function testShouldDoNothingOnBeforeReceive() + public function testShouldImplementPostMessageReceivedExtensionInterface() { - $extension = new ReplyExtension(); - - $extension->onBeforeReceive(new Context(new NullContext())); - } - - public function testShouldDoNothingOnInterrupted() - { - $extension = new ReplyExtension(); - - $extension->onInterrupted(new Context(new NullContext())); + $this->assertClassImplements(PostMessageReceivedExtensionInterface::class, ReplyExtension::class); } public function testShouldDoNothingIfReceivedMessageNotHaveReplyToSet() { $extension = new ReplyExtension(); - $context = new Context(new NullContext()); - $context->setPsrMessage(new NullMessage()); + $postReceivedMessage = new PostMessageReceived( + $this->createNeverUsedContextMock(), + $this->createMock(Consumer::class), + new NullMessage(), + 'aResult', + 1, + new NullLogger() + ); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); } - public function testThrowIfResultNotInstanceOfResult() + public function testShouldDoNothingIfContextResultIsNotInstanceOfResult() { $extension = new ReplyExtension(); $message = new NullMessage(); $message->setReplyTo('aReplyToQueue'); - $context = new Context(new NullContext()); - $context->setPsrMessage($message); - $context->setResult('notInstanceOfResult'); + $postReceivedMessage = new PostMessageReceived( + $this->createNeverUsedContextMock(), + $this->createMock(Consumer::class), + $message, + 'notInstanceOfResult', + 1, + new NullLogger() + ); - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('To send a reply an instance of Result class has to returned from a Processor.'); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); } - public function testThrowIfResultInstanceOfResultButReplyMessageNotSet() + public function testShouldDoNothingIfResultInstanceOfResultButReplyMessageNotSet() { $extension = new ReplyExtension(); $message = new NullMessage(); $message->setReplyTo('aReplyToQueue'); - $context = new Context(new NullContext()); - $context->setPsrMessage($message); - $context->setResult(Result::ack()); + $postReceivedMessage = new PostMessageReceived( + $this->createNeverUsedContextMock(), + $this->createMock(Consumer::class), + $message, + Result::ack(), + 1, + new NullLogger() + ); - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('To send a reply the Result must contain a reply message.'); - $extension->onPostReceived($context); + $extension->onPostMessageReceived($postReceivedMessage); } public function testShouldSendReplyMessageToReplyQueueOnPostReceived() @@ -110,14 +92,15 @@ public function testShouldSendReplyMessageToReplyQueueOnPostReceived() $replyQueue = new NullQueue('aReplyName'); - $producerMock = $this->createMock(Producer::class); + $producerMock = $this->createMock(InteropProducer::class); $producerMock ->expects($this->once()) ->method('send') ->with($replyQueue, $replyMessage) ; - $contextMock = $this->createMock(PsrContext::class); + /** @var MockObject|Context $contextMock */ + $contextMock = $this->createMock(Context::class); $contextMock ->expects($this->once()) ->method('createQueue') @@ -129,10 +112,37 @@ public function testShouldSendReplyMessageToReplyQueueOnPostReceived() ->willReturn($producerMock) ; - $context = new Context($contextMock); - $context->setPsrMessage($message); - $context->setResult(Result::reply($replyMessage)); + $postReceivedMessage = new PostMessageReceived( + $contextMock, + $this->createMock(Consumer::class), + $message, + Result::reply($replyMessage), + 1, + new NullLogger() + ); + + $extension->onPostMessageReceived($postReceivedMessage); + } + + /** + * @return MockObject + */ + protected function createInteropContextMock(): Context + { + return $this->createMock(Context::class); + } + + /** + * @return MockObject + */ + private function createNeverUsedContextMock(): Context + { + $contextMock = $this->createMock(Context::class); + $contextMock + ->expects($this->never()) + ->method('createProducer') + ; - $extension->onPostReceived($context); + return $contextMock; } } diff --git a/pkg/enqueue/Tests/Consumption/ExtensionsTest.php b/pkg/enqueue/Tests/Consumption/ExtensionsTest.php deleted file mode 100644 index 2f35bc76f..000000000 --- a/pkg/enqueue/Tests/Consumption/ExtensionsTest.php +++ /dev/null @@ -1,171 +0,0 @@ -assertClassImplements(ExtensionInterface::class, ChainExtension::class); - } - - public function testCouldBeConstructedWithExtensionsArray() - { - new ChainExtension([$this->createExtension(), $this->createExtension()]); - } - - public function testShouldProxyOnStartToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onStart') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onStart') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onStart($context); - } - - public function testShouldProxyOnBeforeReceiveToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onBeforeReceive') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onBeforeReceive') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onBeforeReceive($context); - } - - public function testShouldProxyOnPreReceiveToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onPreReceived') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onPreReceived') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onPreReceived($context); - } - - public function testShouldProxyOnPostReceiveToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onPostReceived') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onPostReceived') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onPostReceived($context); - } - - public function testShouldProxyOnIdleToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onIdle') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onIdle') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onIdle($context); - } - - public function testShouldProxyOnInterruptedToAllInternalExtensions() - { - $context = $this->createContextMock(); - - $fooExtension = $this->createExtension(); - $fooExtension - ->expects($this->once()) - ->method('onInterrupted') - ->with($this->identicalTo($context)) - ; - $barExtension = $this->createExtension(); - $barExtension - ->expects($this->once()) - ->method('onInterrupted') - ->with($this->identicalTo($context)) - ; - - $extensions = new ChainExtension([$fooExtension, $barExtension]); - - $extensions->onInterrupted($context); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context - */ - protected function createContextMock() - { - return $this->createMock(Context::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ExtensionInterface - */ - protected function createExtension() - { - return $this->createMock(ExtensionInterface::class); - } -} diff --git a/pkg/enqueue/Tests/Consumption/FallbackSubscriptionConsumerTest.php b/pkg/enqueue/Tests/Consumption/FallbackSubscriptionConsumerTest.php new file mode 100644 index 000000000..73fba7bfd --- /dev/null +++ b/pkg/enqueue/Tests/Consumption/FallbackSubscriptionConsumerTest.php @@ -0,0 +1,253 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + public function testShouldInitSubscribersPropertyWithEmptyArray() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $this->assertAttributeSame([], 'subscribers', $subscriptionConsumer); + } + + public function testShouldAddConsumerAndCallbackToSubscribersPropertyOnSubscribe() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + + $this->assertAttributeSame([ + 'foo_queue' => [$fooConsumer, $fooCallback], + 'bar_queue' => [$barConsumer, $barCallback], + ], 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTrySubscribeAnotherConsumerToAlreadySubscribedQueue() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('There is a consumer subscribed to queue: "foo_queue"'); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAllowSubscribeSameConsumerAndCallbackSecondTime() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + } + + public function testShouldRemoveSubscribedConsumerOnUnsubscribeCall() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $fooConsumer = $this->createConsumerStub('foo_queue'); + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, function () {}); + $subscriptionConsumer->subscribe($barConsumer, function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($fooConsumer); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedQueueName() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('bar_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedConsumer() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('foo_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldRemoveAllSubscriberOnUnsubscribeAllCall() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + $subscriptionConsumer->subscribe($this->createConsumerStub('bar_queue'), function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribeAll(); + + $this->assertAttributeCount(0, 'subscribers', $subscriptionConsumer); + } + + public function testShouldConsumeMessagesFromTwoQueuesInExpectedOrder() + { + $firstMessage = $this->createMessageStub('first'); + $secondMessage = $this->createMessageStub('second'); + $thirdMessage = $this->createMessageStub('third'); + $fourthMessage = $this->createMessageStub('fourth'); + $fifthMessage = $this->createMessageStub('fifth'); + + $fooConsumer = $this->createConsumerStub('foo_queue'); + $fooConsumer + ->expects($this->any()) + ->method('receiveNoWait') + ->willReturnOnConsecutiveCalls(null, $firstMessage, null, $secondMessage, $thirdMessage) + ; + + $barConsumer = $this->createConsumerStub('bar_queue'); + $barConsumer + ->expects($this->any()) + ->method('receiveNoWait') + ->willReturnOnConsecutiveCalls($fourthMessage, null, null, $fifthMessage) + ; + + $actualOrder = []; + $callback = function (InteropMessage $message, Consumer $consumer) use (&$actualOrder) { + $actualOrder[] = [$message->getBody(), $consumer->getQueue()->getQueueName()]; + }; + + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $subscriptionConsumer->subscribe($fooConsumer, $callback); + $subscriptionConsumer->subscribe($barConsumer, $callback); + + $subscriptionConsumer->consume(100); + + $this->assertEquals([ + ['fourth', 'bar_queue'], + ['first', 'foo_queue'], + ['second', 'foo_queue'], + ['fifth', 'bar_queue'], + ['third', 'foo_queue'], + ], $actualOrder); + } + + public function testThrowsIfTryConsumeWithoutSubscribers() + { + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('No subscribers'); + $subscriptionConsumer->consume(); + } + + public function testShouldConsumeTillTimeoutIsReached() + { + $fooConsumer = $this->createConsumerStub('foo_queue'); + $fooConsumer + ->expects($this->any()) + ->method('receiveNoWait') + ->willReturn(null) + ; + + $subscriptionConsumer = new FallbackSubscriptionConsumer(); + + $subscriptionConsumer->subscribe($fooConsumer, function () {}); + + $startAt = microtime(true); + $subscriptionConsumer->consume(500); + $endAt = microtime(true); + + $this->assertGreaterThan(0.49, $endAt - $startAt); + } + + /** + * @param mixed|null $body + * + * @return InteropMessage|\PHPUnit\Framework\MockObject\MockObject + */ + private function createMessageStub($body = null) + { + $messageMock = $this->createMock(InteropMessage::class); + $messageMock + ->expects($this->any()) + ->method('getBody') + ->willReturn($body) + ; + + return $messageMock; + } + + /** + * @param mixed|null $queueName + * + * @return Consumer|\PHPUnit\Framework\MockObject\MockObject + */ + private function createConsumerStub($queueName = null) + { + $queueMock = $this->createMock(InteropQueue::class); + $queueMock + ->expects($this->any()) + ->method('getQueueName') + ->willReturn($queueName); + + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queueMock) + ; + + return $consumerMock; + } +} diff --git a/pkg/enqueue/Tests/Consumption/Mock/BreakCycleExtension.php b/pkg/enqueue/Tests/Consumption/Mock/BreakCycleExtension.php index b61d8dd8d..cbc2f8b1e 100644 --- a/pkg/enqueue/Tests/Consumption/Mock/BreakCycleExtension.php +++ b/pkg/enqueue/Tests/Consumption/Mock/BreakCycleExtension.php @@ -2,14 +2,20 @@ namespace Enqueue\Tests\Consumption\Mock; -use Enqueue\Consumption\Context; -use Enqueue\Consumption\EmptyExtensionTrait; +use Enqueue\Consumption\Context\End; +use Enqueue\Consumption\Context\InitLogger; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\Context\MessageResult; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\Context\PreSubscribe; +use Enqueue\Consumption\Context\ProcessorException; +use Enqueue\Consumption\Context\Start; use Enqueue\Consumption\ExtensionInterface; class BreakCycleExtension implements ExtensionInterface { - use EmptyExtensionTrait; - protected $cycles = 1; private $limit; @@ -19,15 +25,51 @@ public function __construct($limit) $this->limit = $limit; } - public function onPostReceived(Context $context) + public function onInitLogger(InitLogger $context): void + { + } + + public function onPostMessageReceived(PostMessageReceived $context): void + { + if ($this->cycles >= $this->limit) { + $context->interruptExecution(); + } else { + ++$this->cycles; + } + } + + public function onEnd(End $context): void + { + } + + public function onMessageReceived(MessageReceived $context): void + { + } + + public function onResult(MessageResult $context): void + { + } + + public function onPreConsume(PreConsume $context): void + { + } + + public function onPreSubscribe(PreSubscribe $context): void + { + } + + public function onProcessorException(ProcessorException $context): void + { + } + + public function onStart(Start $context): void { - $this->onIdle($context); } - public function onIdle(Context $context) + public function onPostConsume(PostConsume $context): void { if ($this->cycles >= $this->limit) { - $context->setExecutionInterrupted(true); + $context->interruptExecution(); } else { ++$this->cycles; } diff --git a/pkg/enqueue/Tests/Consumption/Mock/DummySubscriptionConsumer.php b/pkg/enqueue/Tests/Consumption/Mock/DummySubscriptionConsumer.php new file mode 100644 index 000000000..40351484d --- /dev/null +++ b/pkg/enqueue/Tests/Consumption/Mock/DummySubscriptionConsumer.php @@ -0,0 +1,48 @@ +messages as list($message, $queueName)) { + /** @var InteropMessage $message */ + /** @var string $queueName */ + if (false == call_user_func($this->subscriptions[$queueName][1], $message, $this->subscriptions[$queueName][0])) { + return; + } + } + } + + public function subscribe(Consumer $consumer, callable $callback): void + { + $this->subscriptions[$consumer->getQueue()->getQueueName()] = [$consumer, $callback]; + } + + public function unsubscribe(Consumer $consumer): void + { + unset($this->subscriptions[$consumer->getQueue()->getQueueName()]); + } + + public function unsubscribeAll(): void + { + $this->subscriptions = []; + } + + public function addMessage(InteropMessage $message, string $queueName): void + { + $this->messages[] = [$message, $queueName]; + } +} diff --git a/pkg/enqueue/Tests/Consumption/QueueConsumerTest.php b/pkg/enqueue/Tests/Consumption/QueueConsumerTest.php index 06988b3cc..2bcc253e7 100644 --- a/pkg/enqueue/Tests/Consumption/QueueConsumerTest.php +++ b/pkg/enqueue/Tests/Consumption/QueueConsumerTest.php @@ -2,60 +2,92 @@ namespace Enqueue\Tests\Consumption; +use Enqueue\Consumption\BoundProcessor; use Enqueue\Consumption\CallbackProcessor; use Enqueue\Consumption\ChainExtension; -use Enqueue\Consumption\Context; +use Enqueue\Consumption\Context\End; +use Enqueue\Consumption\Context\InitLogger; +use Enqueue\Consumption\Context\MessageReceived; +use Enqueue\Consumption\Context\MessageResult; +use Enqueue\Consumption\Context\PostConsume; +use Enqueue\Consumption\Context\PostMessageReceived; +use Enqueue\Consumption\Context\PreConsume; +use Enqueue\Consumption\Context\PreSubscribe; +use Enqueue\Consumption\Context\ProcessorException; +use Enqueue\Consumption\Context\Start; use Enqueue\Consumption\Exception\InvalidArgumentException; +use Enqueue\Consumption\Extension\ExitStatusExtension; use Enqueue\Consumption\ExtensionInterface; use Enqueue\Consumption\QueueConsumer; use Enqueue\Consumption\Result; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context as PsrContext; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; -use Enqueue\Psr\Queue; +use Enqueue\Null\NullQueue; +use Enqueue\Test\ReadAttributeTrait; use Enqueue\Tests\Consumption\Mock\BreakCycleExtension; -use Enqueue\Transport\Null\NullQueue; +use Enqueue\Tests\Consumption\Mock\DummySubscriptionConsumer; +use Interop\Queue\Consumer; +use Interop\Queue\Context as InteropContext; +use Interop\Queue\Exception\SubscriptionConsumerNotSupportedException; +use Interop\Queue\Message; +use Interop\Queue\Processor; +use Interop\Queue\Queue; +use Interop\Queue\SubscriptionConsumer; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -class QueueConsumerTest extends \PHPUnit_Framework_TestCase +class QueueConsumerTest extends TestCase { - public function testCouldBeConstructedWithConnectionAndExtensionsAsArguments() + use ReadAttributeTrait; + + public function testShouldSetEmptyArrayToBoundProcessorsPropertyInConstructor() { - new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub(), null, [], null, 0); + + $this->assertAttributeSame([], 'boundProcessors', $consumer); } - public function testCouldBeConstructedWithConnectionOnly() + public function testShouldSetProvidedBoundProcessorsToThePropertyInConstructor() { - new QueueConsumer($this->createPsrContextStub()); + $boundProcessors = [ + new BoundProcessor(new NullQueue('foo'), $this->createProcessorMock()), + new BoundProcessor(new NullQueue('bar'), $this->createProcessorMock()), + ]; + + $consumer = new QueueConsumer($this->createContextStub(), null, $boundProcessors, null, 0); + + $this->assertAttributeSame($boundProcessors, 'boundProcessors', $consumer); } - public function testCouldBeConstructedWithConnectionAndSingleExtension() + public function testShouldSetNullLoggerIfNoneProvidedInConstructor() { - new QueueConsumer($this->createPsrContextStub(), $this->createExtension()); + $consumer = new QueueConsumer($this->createContextStub(), null, [], null, 0); + + $this->assertAttributeInstanceOf(NullLogger::class, 'logger', $consumer); } - public function testShouldSetEmptyArrayToBoundProcessorsPropertyInConstructor() + public function testShouldSetProvidedLoggerToThePropertyInConstructor() { - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $expectedLogger = $this->createMock(LoggerInterface::class); - $this->assertAttributeSame([], 'boundProcessors', $consumer); + $consumer = new QueueConsumer($this->createContextStub(), null, [], $expectedLogger, 0); + + $this->assertAttributeSame($expectedLogger, 'logger', $consumer); } - public function testShouldAllowGetConnectionSetInConstructor() + public function testShouldAllowGetContextSetInConstructor() { - $expectedConnection = $this->createPsrContextStub(); + $expectedContext = $this->createContextStub(); - $consumer = new QueueConsumer($expectedConnection, null, 0); + $consumer = new QueueConsumer($expectedContext, null, [], null, 0); - $this->assertSame($expectedConnection, $consumer->getPsrContext()); + $this->assertSame($expectedContext, $consumer->getContext()); } public function testThrowIfQueueNameEmptyOnBind() { $processorMock = $this->createProcessorMock(); - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); $this->expectException(\LogicException::class); $this->expectExceptionMessage('The queue name must be not empty.'); @@ -66,7 +98,7 @@ public function testThrowIfQueueAlreadyBoundToProcessorOnBind() { $processorMock = $this->createProcessorMock(); - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); $consumer->bind(new NullQueue('theQueueName'), $processorMock); @@ -80,31 +112,35 @@ public function testShouldAllowBindProcessorToQueue() $queue = new NullQueue('theQueueName'); $processorMock = $this->createProcessorMock(); - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); $consumer->bind($queue, $processorMock); - $this->assertAttributeSame(['theQueueName' => [$queue, $processorMock]], 'boundProcessors', $consumer); + $this->assertAttributeEquals( + ['theQueueName' => new BoundProcessor($queue, $processorMock)], + 'boundProcessors', + $consumer + ); } public function testThrowIfQueueNeitherInstanceOfQueueNorString() { $processorMock = $this->createProcessorMock(); - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The argument must be an instance of Enqueue\Psr\Queue but got stdClass.'); + $this->expectExceptionMessage('The argument must be an instance of Interop\Queue\Queue but got stdClass.'); $consumer->bind(new \stdClass(), $processorMock); } - public function testThrowIfProcessorNeitherInstanceOfProcessorNorCallable() + public function testCouldSetGetReceiveTimeout() { - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The argument must be an instance of Enqueue\Psr\Processor but got stdClass.'); - $consumer->bind(new NullQueue(''), new \stdClass()); + $consumer->setReceiveTimeout(123456); + + $this->assertSame(123456, $consumer->getReceiveTimeout()); } public function testShouldAllowBindCallbackToQueueName() @@ -115,7 +151,7 @@ public function testShouldAllowBindCallbackToQueueName() $queueName = 'theQueueName'; $queue = new NullQueue($queueName); - $context = $this->createMock(PsrContext::class); + $context = $this->createContextWithoutSubscriptionConsumerMock(); $context ->expects($this->once()) ->method('createQueue') @@ -123,48 +159,159 @@ public function testShouldAllowBindCallbackToQueueName() ->willReturn($queue) ; - $consumer = new QueueConsumer($context, null, 0); + $consumer = new QueueConsumer($context); - $consumer->bind($queueName, $callback); + $consumer->bindCallback($queueName, $callback); $boundProcessors = $this->readAttribute($consumer, 'boundProcessors'); - $this->assertInternalType('array', $boundProcessors); + self::assertIsArray($boundProcessors); $this->assertCount(1, $boundProcessors); $this->assertArrayHasKey($queueName, $boundProcessors); - $this->assertInternalType('array', $boundProcessors[$queueName]); - $this->assertCount(2, $boundProcessors[$queueName]); - $this->assertSame($queue, $boundProcessors[$queueName][0]); - $this->assertInstanceOf(CallbackProcessor::class, $boundProcessors[$queueName][1]); + $this->assertInstanceOf(BoundProcessor::class, $boundProcessors[$queueName]); + $this->assertSame($queue, $boundProcessors[$queueName]->getQueue()); + $this->assertInstanceOf(CallbackProcessor::class, $boundProcessors[$queueName]->getProcessor()); } public function testShouldReturnSelfOnBind() { $processorMock = $this->createProcessorMock(); - $consumer = new QueueConsumer($this->createPsrContextStub(), null, 0); + $consumer = new QueueConsumer($this->createContextStub()); - $this->assertSame($consumer, $consumer->bind(new NullQueue('aQueueName'), $processorMock)); + $this->assertSame($consumer, $consumer->bind(new NullQueue('foo_queue'), $processorMock)); } - public function testShouldSubscribeToGivenQueueAndQuitAfterFifthIdleCycle() + public function testShouldUseContextSubscriptionConsumerIfSupport() { $expectedQueue = new NullQueue('theQueueName'); - $messageConsumerMock = $this->createMock(Consumer::class); - $messageConsumerMock + $contextSubscriptionConsumer = $this->createSubscriptionConsumerMock(); + $contextSubscriptionConsumer + ->expects($this->once()) + ->method('consume') + ; + + $fallbackSubscriptionConsumer = $this->createSubscriptionConsumerMock(); + $fallbackSubscriptionConsumer + ->expects($this->never()) + ->method('consume') + ; + + $contextMock = $this->createMock(InteropContext::class); + $contextMock + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($expectedQueue)) + ->willReturn($this->createConsumerStub()) + ; + $contextMock + ->expects($this->once()) + ->method('createSubscriptionConsumer') + ->willReturn($contextSubscriptionConsumer) + ; + + $processorMock = $this->createProcessorMock(); + $processorMock + ->expects($this->never()) + ->method('process') + ; + + $queueConsumer = new QueueConsumer($contextMock, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($fallbackSubscriptionConsumer); + $queueConsumer->bind($expectedQueue, $processorMock); + $queueConsumer->consume(); + } + + public function testShouldUseFallbackSubscriptionConsumerIfNotSupported() + { + $expectedQueue = new NullQueue('theQueueName'); + + $contextSubscriptionConsumer = $this->createSubscriptionConsumerMock(); + $contextSubscriptionConsumer + ->expects($this->never()) + ->method('consume') + ; + + $fallbackSubscriptionConsumer = $this->createSubscriptionConsumerMock(); + $fallbackSubscriptionConsumer + ->expects($this->once()) + ->method('consume') + ; + + $contextMock = $this->createContextWithoutSubscriptionConsumerMock(); + $contextMock + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($expectedQueue)) + ->willReturn($this->createConsumerStub()) + ; + $contextMock + ->expects($this->once()) + ->method('createSubscriptionConsumer') + ->willThrowException(SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt()) + ; + + $processorMock = $this->createProcessorMock(); + $processorMock + ->expects($this->never()) + ->method('process') + ; + + $queueConsumer = new QueueConsumer($contextMock, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($fallbackSubscriptionConsumer); + $queueConsumer->bind($expectedQueue, $processorMock); + $queueConsumer->consume(); + } + + public function testShouldSubscribeToGivenQueueWithExpectedTimeout() + { + $expectedQueue = new NullQueue('theQueueName'); + + $subscriptionConsumerMock = $this->createSubscriptionConsumerMock(); + $subscriptionConsumerMock + ->expects($this->once()) + ->method('consume') + ->with(12345) + ; + + $contextMock = $this->createContextWithoutSubscriptionConsumerMock(); + $contextMock + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($expectedQueue)) + ->willReturn($this->createConsumerStub()) + ; + + $processorMock = $this->createProcessorMock(); + $processorMock + ->expects($this->never()) + ->method('process') + ; + + $queueConsumer = new QueueConsumer($contextMock, new BreakCycleExtension(1), [], null, 12345); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind($expectedQueue, $processorMock); + $queueConsumer->consume(); + } + + public function testShouldSubscribeToGivenQueueAndQuitAfterFifthConsumeCycle() + { + $expectedQueue = new NullQueue('theQueueName'); + + $subscriptionConsumerMock = $this->createSubscriptionConsumerMock(); + $subscriptionConsumerMock ->expects($this->exactly(5)) - ->method('receive') - ->willReturn(null) + ->method('consume') ; - $contextMock = $this->createMock(PsrContext::class); + $contextMock = $this->createContextWithoutSubscriptionConsumerMock(); $contextMock ->expects($this->once()) ->method('createConsumer') ->with($this->identicalTo($expectedQueue)) - ->willReturn($messageConsumerMock) + ->willReturn($this->createConsumerStub()) ; $processorMock = $this->createProcessorMock(); @@ -173,17 +320,30 @@ public function testShouldSubscribeToGivenQueueAndQuitAfterFifthIdleCycle() ->method('process') ; - $queueConsumer = new QueueConsumer($contextMock, new BreakCycleExtension(5), 0); + $queueConsumer = new QueueConsumer($contextMock, new BreakCycleExtension(5)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); $queueConsumer->bind($expectedQueue, $processorMock); $queueConsumer->consume(); } public function testShouldProcessFiveMessagesAndQuit() { - $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); + $fooQueue = new NullQueue('foo_queue'); + + $firstMessageMock = $this->createMessageMock(); + $secondMessageMock = $this->createMessageMock(); + $thirdMessageMock = $this->createMessageMock(); + $fourthMessageMock = $this->createMessageMock(); + $fifthMessageMock = $this->createMessageMock(); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($firstMessageMock, 'foo_queue'); + $subscriptionConsumerMock->addMessage($secondMessageMock, 'foo_queue'); + $subscriptionConsumerMock->addMessage($thirdMessageMock, 'foo_queue'); + $subscriptionConsumerMock->addMessage($fourthMessageMock, 'foo_queue'); + $subscriptionConsumerMock->addMessage($fifthMessageMock, 'foo_queue'); + + $contextStub = $this->createContextStub(); $processorMock = $this->createProcessorMock(); $processorMock @@ -192,8 +352,9 @@ public function testShouldProcessFiveMessagesAndQuit() ->willReturn(Result::ACK) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(5), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(5)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind($fooQueue, $processorMock); $queueConsumer->consume(); } @@ -201,14 +362,18 @@ public function testShouldProcessFiveMessagesAndQuit() public function testShouldAckMessageIfProcessorReturnSuchStatus() { $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $messageConsumerStub + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + $consumerStub ->expects($this->once()) ->method('acknowledge') ->with($this->identicalTo($messageMock)) ; - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -218,8 +383,9 @@ public function testShouldAckMessageIfProcessorReturnSuchStatus() ->willReturn(Result::ACK) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } @@ -227,9 +393,13 @@ public function testShouldAckMessageIfProcessorReturnSuchStatus() public function testThrowIfProcessorReturnNull() { $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -239,8 +409,9 @@ public function testThrowIfProcessorReturnNull() ->willReturn(null) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $this->expectException(\LogicException::class); $this->expectExceptionMessage('Status is not supported'); @@ -250,14 +421,18 @@ public function testThrowIfProcessorReturnNull() public function testShouldRejectMessageIfProcessorReturnSuchStatus() { $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $messageConsumerStub + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + $consumerStub ->expects($this->once()) ->method('reject') ->with($this->identicalTo($messageMock), false) ; - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -267,8 +442,43 @@ public function testShouldRejectMessageIfProcessorReturnSuchStatus() ->willReturn(Result::REJECT) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldDoNothingIfProcessorReturnsAlreadyAcknowledged() + { + $messageMock = $this->createMessageMock(); + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + $consumerStub + ->expects($this->never()) + ->method('reject') + ; + $consumerStub + ->expects($this->never()) + ->method('acknowledge') + ; + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorMock(); + $processorMock + ->expects($this->once()) + ->method('process') + ->with($this->identicalTo($messageMock)) + ->willReturn(Result::ALREADY_ACKNOWLEDGED) + ; + + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } @@ -276,14 +486,18 @@ public function testShouldRejectMessageIfProcessorReturnSuchStatus() public function testShouldRequeueMessageIfProcessorReturnSuchStatus() { $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $messageConsumerStub + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + $consumerStub ->expects($this->once()) ->method('reject') ->with($this->identicalTo($messageMock), true) ; - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -293,8 +507,9 @@ public function testShouldRequeueMessageIfProcessorReturnSuchStatus() ->willReturn(Result::REQUEUE) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } @@ -302,9 +517,13 @@ public function testShouldRequeueMessageIfProcessorReturnSuchStatus() public function testThrowIfProcessorReturnInvalidStatus() { $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -314,8 +533,9 @@ public function testThrowIfProcessorReturnInvalidStatus() ->willReturn('invalidStatus') ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $this->expectException(\LogicException::class); $this->expectExceptionMessage('Status is not supported: invalidStatus'); @@ -327,17 +547,21 @@ public function testShouldNotPassMessageToProcessorIfItWasProcessedByExtension() $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onPreReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) { - $context->setResult(Result::ACK); + ->method('onMessageReceived') + ->with($this->isInstanceOf(MessageReceived::class)) + ->willReturnCallback(function (MessageReceived $context) { + $context->setResult(Result::ack()); }) ; $messageMock = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($messageMock); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($messageMock, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -346,17 +570,46 @@ public function testShouldNotPassMessageToProcessorIfItWasProcessedByExtension() ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallOnInitLoggerExtensionMethod() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorMock(); + + $logger = $this->createMock(LoggerInterface::class); + + $extension = $this->createExtension(); + $extension + ->expects($this->once()) + ->method('onInitLogger') + ->with($this->isInstanceOf(InitLogger::class)) + ->willReturnCallback(function (InitLogger $context) use ($logger) { + $this->assertSame($logger, $context->getLogger()); + }) + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, [], $logger); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } public function testShouldCallOnStartExtensionMethod() { - $messageConsumerStub = $this->createMessageConsumerStub($message = null); + $consumerStub = $this->createConsumerStub('foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); @@ -364,282 +617,488 @@ public function testShouldCallOnStartExtensionMethod() $extension ->expects($this->once()) ->method('onStart') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertNull($context->getPsrConsumer()); - $this->assertNull($context->getPsrProcessor()); - $this->assertNull($context->getLogger()); - $this->assertNull($context->getPsrMessage()); - $this->assertNull($context->getException()); - $this->assertNull($context->getResult()); - $this->assertNull($context->getPsrQueue()); - $this->assertFalse($context->isExecutionInterrupted()); + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($contextStub) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertInstanceOf(NullLogger::class, $context->getLogger()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldCallOnIdleExtensionMethod() + public function testShouldCallOnStartWithLoggerProvidedInConstructor() { - $messageConsumerStub = $this->createMessageConsumerStub($message = null); + $consumerStub = $this->createConsumerStub('foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); + $expectedLogger = $this->createMock(LoggerInterface::class); + $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onIdle') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); + ->method('onStart') + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($expectedLogger) { + $this->assertSame($expectedLogger, $context->getLogger()); + }) + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, [], $expectedLogger); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldInterruptExecutionOnStart() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorMock(); + $processorMock + ->expects($this->never()) + ->method('process') + ; + + $expectedLogger = $this->createMock(LoggerInterface::class); + + $extension = $this->createExtension(); + $extension + ->expects($this->once()) + ->method('onStart') + ->willReturnCallback(function (Start $context) { + $context->interruptExecution(); + }) + ; + $extension + ->expects($this->once()) + ->method('onEnd') + ; + $extension + ->expects($this->never()) + ->method('onPreConsume') + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, [], $expectedLogger); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallPreSubscribeExtensionMethod() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorMock(); + + $extension = $this->createExtension(); + $extension + ->expects($this->once()) + ->method('onPreSubscribe') + ->with($this->isInstanceOf(PreSubscribe::class)) + ->willReturnCallback(function (PreSubscribe $context) use ($contextStub, $consumerStub, $processorMock) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($consumerStub, $context->getConsumer()); + $this->assertSame($processorMock, $context->getProcessor()); + $this->assertInstanceOf(NullLogger::class, $context->getLogger()); + }) + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallPreSubscribeForEachBoundProcessor() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorMock(); + + $extension = $this->createExtension(); + $extension + ->expects($this->exactly(3)) + ->method('onPreSubscribe') + ->with($this->isInstanceOf(PreSubscribe::class)) + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + $queueConsumer->bind(new NullQueue('bar_queue'), $processorMock); + $queueConsumer->bind(new NullQueue('baz_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallOnPostConsumeExtensionMethod() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $subscriptionConsumer = new DummySubscriptionConsumer(); + + $processorMock = $this->createProcessorMock(); + + $extension = $this->createExtension(); + $extension + ->expects($this->once()) + ->method('onPostConsume') + ->with($this->isInstanceOf(PostConsume::class)) + ->willReturnCallback(function (PostConsume $context) use ($contextStub, $subscriptionConsumer) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($subscriptionConsumer, $context->getSubscriptionConsumer()); + $this->assertSame(1, $context->getCycle()); + $this->assertSame(0, $context->getReceivedMessagesCount()); + $this->assertGreaterThan(1, $context->getStartTime()); $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getPsrMessage()); - $this->assertNull($context->getException()); - $this->assertNull($context->getResult()); $this->assertFalse($context->isExecutionInterrupted()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumer); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldCallOnBeforeReceiveExtensionMethod() + public function testShouldCallOnPreConsumeExtensionMethod() { - $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); + $consumerStub = $this->createConsumerStub('foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorStub(); - $queue = new NullQueue('aQueueName'); + $queue = new NullQueue('foo_queue'); $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onBeforeReceive') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock, - $expectedMessage, - $queue - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); + ->method('onPreConsume') + ->with($this->isInstanceOf(PreConsume::class)) + ->willReturnCallback(function (PreConsume $context) use ($contextStub) { + $this->assertSame($contextStub, $context->getContext()); $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getPsrMessage()); - $this->assertNull($context->getException()); - $this->assertNull($context->getResult()); + $this->assertInstanceOf(SubscriptionConsumer::class, $context->getSubscriptionConsumer()); + $this->assertSame(10000, $context->getReceiveTimeout()); + $this->assertSame(1, $context->getCycle()); + $this->assertGreaterThan(0, $context->getStartTime()); $this->assertFalse($context->isExecutionInterrupted()); - $this->assertSame($queue, $context->getPsrQueue()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind($queue, $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallOnPreConsumeExpectedAmountOfTimes() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorStub(); + + $queue = new NullQueue('foo_queue'); + + $extension = $this->createExtension(); + $extension + ->expects($this->exactly(3)) + ->method('onPreConsume') + ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(3)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); $queueConsumer->bind($queue, $processorMock); $queueConsumer->consume(); } - public function testShouldCallOnPreReceivedAndPostReceivedExtensionMethods() + public function testShouldCallOnPreReceivedExtensionMethodWithExpectedContext() { $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorStub(); $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onPreReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( + ->method('onMessageReceived') + ->with($this->isInstanceOf(MessageReceived::class)) + ->willReturnCallback(function (MessageReceived $context) use ( $contextStub, - $messageConsumerStub, + $consumerStub, $processorMock, $expectedMessage ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($expectedMessage, $context->getPsrMessage()); + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($consumerStub, $context->getConsumer()); + $this->assertSame($processorMock, $context->getProcessor()); + $this->assertSame($expectedMessage, $context->getMessage()); $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getException()); $this->assertNull($context->getResult()); - $this->assertFalse($context->isExecutionInterrupted()); }) ; + + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldCallOnResultExtensionMethodWithExpectedContext() + { + $expectedMessage = $this->createMessageMock(); + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorStub(); + + $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onPostReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock, - $expectedMessage - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($expectedMessage, $context->getPsrMessage()); + ->method('onResult') + ->with($this->isInstanceOf(MessageResult::class)) + ->willReturnCallback(function (MessageResult $context) use ($contextStub, $expectedMessage) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($expectedMessage, $context->getMessage()); $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getException()); $this->assertSame(Result::ACK, $context->getResult()); - $this->assertFalse($context->isExecutionInterrupted()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldAllowInterruptConsumingOnIdle() + public function testShouldCallOnProcessorExceptionExtensionMethodWithExpectedContext() { - $messageConsumerStub = $this->createMessageConsumerStub($message = null); + $exception = new \LogicException('Exception exception'); + + $expectedMessage = $this->createMessageMock(); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); - $processorMock = $this->createProcessorMock(); + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorStub(); + $processorMock + ->expects($this->once()) + ->method('process') + ->willThrowException($exception) + ; $extension = $this->createExtension(); $extension - ->expects($this->once()) - ->method('onIdle') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) { - $context->setExecutionInterrupted(true); - }) + ->expects($this->never()) + ->method('onResult') ; $extension ->expects($this->once()) - ->method('onInterrupted') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); + ->method('onProcessorException') + ->with($this->isInstanceOf(ProcessorException::class)) + ->willReturnCallback(function (ProcessorException $context) use ($contextStub, $expectedMessage, $exception) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($expectedMessage, $context->getMessage()); + $this->assertSame($exception, $context->getException()); + $this->assertGreaterThan(1, $context->getReceivedAt()); $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getPsrMessage()); - $this->assertNull($context->getException()); $this->assertNull($context->getResult()); - $this->assertTrue($context->isExecutionInterrupted()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Exception exception'); $queueConsumer->consume(); } - public function testShouldCloseSessionWhenConsumptionInterrupted() + public function testShouldContinueConsumptionIfResultSetOnProcessorExceptionExtension() { - $messageConsumerStub = $this->createMessageConsumerStub($message = null); + $result = Result::ack(); - $contextStub = $this->createPsrContextStub($messageConsumerStub); - $contextStub + $expectedMessage = $this->createMessageMock(); + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorStub(); + $processorMock ->expects($this->once()) - ->method('close') + ->method('process') + ->willThrowException(new \LogicException()) ; - $processorMock = $this->createProcessorMock(); - $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onIdle') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) { - $context->setExecutionInterrupted(true); + ->method('onProcessorException') + ->willReturnCallback(function (ProcessorException $context) use ($result) { + $context->setResult($result); + }) + ; + $extension + ->expects($this->once()) + ->method('onResult') + ->willReturnCallback(function (MessageResult $context) use ($result) { + $this->assertSame($result, $context->getResult()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldCloseSessionWhenConsumptionInterruptedByException() + public function testShouldCallOnPostMessageReceivedExtensionMethodWithExpectedContext() { - $expectedException = new \Exception(); + $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($message = $this->createMessageMock()); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); - $contextStub + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + + $processorMock = $this->createProcessorStub(); + + $extension = $this->createExtension(); + $extension ->expects($this->once()) - ->method('close') + ->method('onPostMessageReceived') + ->with($this->isInstanceOf(PostMessageReceived::class)) + ->willReturnCallback(function (PostMessageReceived $context) use ( + $contextStub, + $expectedMessage + ) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertSame($expectedMessage, $context->getMessage()); + $this->assertInstanceOf(NullLogger::class, $context->getLogger()); + $this->assertSame(Result::ACK, $context->getResult()); + $this->assertFalse($context->isExecutionInterrupted()); + }) ; + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); + + $queueConsumer->consume(); + } + + public function testShouldAllowInterruptConsumingOnPostConsume() + { + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); + $processorMock = $this->createProcessorMock(); - $processorMock + + $extension = $this->createExtension(); + $extension ->expects($this->once()) - ->method('process') - ->willThrowException($expectedException) + ->method('onPostConsume') + ->with($this->isInstanceOf(PostConsume::class)) + ->willReturnCallback(function (PostConsume $context) { + $context->interruptExecution(); + }) + ; + $extension + ->expects($this->once()) + ->method('onEnd') + ->with($this->isInstanceOf(End::class)) + ->willReturnCallback(function (End $context) use ($contextStub) { + $this->assertSame($contextStub, $context->getContext()); + $this->assertInstanceOf(NullLogger::class, $context->getLogger()); + $this->assertGreaterThan(1, $context->getStartTime()); + $this->assertGreaterThan(1, $context->getEndTime()); + }) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); - - try { - $queueConsumer->consume(); - } catch (\Exception $e) { - $this->assertSame($expectedException, $e); - $this->assertNull($e->getPrevious()); - - return; - } + $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer(new DummySubscriptionConsumer()); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); - $this->fail('Exception throw is expected.'); + $queueConsumer->consume(); } - public function testShouldSetMainExceptionAsPreviousToExceptionThrownOnInterrupt() + public function testShouldSetMainExceptionAsPreviousToExceptionThrownOnProcessorException() { $mainException = new \Exception(); $expectedException = new \Exception(); - $messageConsumerStub = $this->createMessageConsumerStub($message = $this->createMessageMock()); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($this->createMessageMock(), 'foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -651,13 +1110,14 @@ public function testShouldSetMainExceptionAsPreviousToExceptionThrownOnInterrupt $extension = $this->createExtension(); $extension ->expects($this->atLeastOnce()) - ->method('onInterrupted') + ->method('onProcessorException') ->willThrowException($expectedException) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); try { $queueConsumer->consume(); @@ -671,63 +1131,16 @@ public function testShouldSetMainExceptionAsPreviousToExceptionThrownOnInterrupt $this->fail('Exception throw is expected.'); } - public function testShouldAllowInterruptConsumingOnPreReceiveButProcessCurrentMessage() + public function testShouldAllowInterruptConsumingOnPostMessageReceived() { $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - - $contextStub = $this->createPsrContextStub($messageConsumerStub); - - $processorMock = $this->createProcessorMock(); - $processorMock - ->expects($this->once()) - ->method('process') - ->willReturn(Result::ACK) - ; - $extension = $this->createExtension(); - $extension - ->expects($this->once()) - ->method('onPreReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) { - $context->setExecutionInterrupted(true); - }) - ; - $extension - ->expects($this->atLeastOnce()) - ->method('onInterrupted') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock, - $expectedMessage - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($expectedMessage, $context->getPsrMessage()); - $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getException()); - $this->assertSame(Result::ACK, $context->getResult()); - $this->assertTrue($context->isExecutionInterrupted()); - }) - ; + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); - $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $consumerStub = $this->createConsumerStub('foo_queue'); - $queueConsumer->consume(); - } - - public function testShouldAllowInterruptConsumingOnPostReceive() - { - $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -739,47 +1152,37 @@ public function testShouldAllowInterruptConsumingOnPostReceive() $extension = $this->createExtension(); $extension ->expects($this->once()) - ->method('onPostReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) { - $context->setExecutionInterrupted(true); + ->method('onPostMessageReceived') + ->with($this->isInstanceOf(PostMessageReceived::class)) + ->willReturnCallback(function (PostMessageReceived $context) { + $context->interruptExecution(); }) ; $extension ->expects($this->atLeastOnce()) - ->method('onInterrupted') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock, - $expectedMessage - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($expectedMessage, $context->getPsrMessage()); - $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getException()); - $this->assertSame(Result::ACK, $context->getResult()); - $this->assertTrue($context->isExecutionInterrupted()); - }) + ->method('onEnd') + ->with($this->isInstanceOf(End::class)) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldCallOnInterruptedIfExceptionThrow() + public function testShouldNotCallOnEndIfExceptionThrow() { $expectedException = new \Exception('Process failed'); $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -790,30 +1193,14 @@ public function testShouldCallOnInterruptedIfExceptionThrow() $extension = $this->createExtension(); $extension - ->expects($this->atLeastOnce()) - ->method('onInterrupted') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ( - $contextStub, - $messageConsumerStub, - $processorMock, - $expectedMessage, - $expectedException - ) { - $this->assertSame($contextStub, $context->getPsrContext()); - $this->assertSame($messageConsumerStub, $context->getPsrConsumer()); - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($expectedMessage, $context->getPsrMessage()); - $this->assertSame($expectedException, $context->getException()); - $this->assertInstanceOf(NullLogger::class, $context->getLogger()); - $this->assertNull($context->getResult()); - $this->assertTrue($context->isExecutionInterrupted()); - }) + ->expects($this->never()) + ->method('onEnd') ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $this->expectException(\Exception::class); $this->expectExceptionMessage('Process failed'); @@ -823,9 +1210,13 @@ public function testShouldCallOnInterruptedIfExceptionThrow() public function testShouldCallExtensionPassedOnRuntime() { $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -835,39 +1226,59 @@ public function testShouldCallExtensionPassedOnRuntime() ; $runtimeExtension = $this->createExtension(); + $runtimeExtension + ->expects($this->once()) + ->method('onInitLogger') + ->with($this->isInstanceOf(InitLogger::class)) + ; $runtimeExtension ->expects($this->once()) ->method('onStart') - ->with($this->isInstanceOf(Context::class)) + ->with($this->isInstanceOf(Start::class)) + ; + $runtimeExtension + ->expects($this->once()) + ->method('onPreSubscribe') + ->with($this->isInstanceOf(PreSubscribe::class)) + ; + $runtimeExtension + ->expects($this->once()) + ->method('onPreConsume') + ->with($this->isInstanceOf(PreConsume::class)) ; $runtimeExtension ->expects($this->once()) - ->method('onBeforeReceive') - ->with($this->isInstanceOf(Context::class)) + ->method('onMessageReceived') + ->with($this->isInstanceOf(MessageReceived::class)) ; $runtimeExtension ->expects($this->once()) - ->method('onPreReceived') - ->with($this->isInstanceOf(Context::class)) + ->method('onResult') + ->with($this->isInstanceOf(MessageResult::class)) ; $runtimeExtension ->expects($this->once()) - ->method('onPostReceived') - ->with($this->isInstanceOf(Context::class)) + ->method('onPostMessageReceived') + ->with($this->isInstanceOf(PostMessageReceived::class)) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1), 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(1)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(new ChainExtension([$runtimeExtension])); } - public function testShouldChangeLoggerOnStart() + public function testShouldChangeLoggerOnInitLogger() { $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($expectedMessage, 'foo_queue'); + + $consumerStub = $this->createConsumerStub('foo_queue'); + + $contextStub = $this->createContextStub($consumerStub); $processorMock = $this->createProcessorMock(); $processorMock @@ -879,134 +1290,204 @@ public function testShouldChangeLoggerOnStart() $expectedLogger = new NullLogger(); $extension = $this->createExtension(); + $extension + ->expects($this->atLeastOnce()) + ->method('onInitLogger') + ->with($this->isInstanceOf(InitLogger::class)) + ->willReturnCallback(function (InitLogger $context) use ($expectedLogger) { + $context->changeLogger($expectedLogger); + }) + ; $extension ->expects($this->atLeastOnce()) ->method('onStart') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ($expectedLogger) { - $context->setLogger($expectedLogger); + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($expectedLogger) { + $this->assertSame($expectedLogger, $context->getLogger()); }) ; $extension ->expects($this->atLeastOnce()) - ->method('onBeforeReceive') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ($expectedLogger) { + ->method('onPreSubscribe') + ->with($this->isInstanceOf(PreSubscribe::class)) + ->willReturnCallback(function (PreSubscribe $context) use ($expectedLogger) { $this->assertSame($expectedLogger, $context->getLogger()); }) ; $extension ->expects($this->atLeastOnce()) - ->method('onPreReceived') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ($expectedLogger) { + ->method('onPreConsume') + ->with($this->isInstanceOf(PreConsume::class)) + ->willReturnCallback(function (PreConsume $context) use ($expectedLogger) { + $this->assertSame($expectedLogger, $context->getLogger()); + }) + ; + $extension + ->expects($this->atLeastOnce()) + ->method('onMessageReceived') + ->with($this->isInstanceOf(MessageReceived::class)) + ->willReturnCallback(function (MessageReceived $context) use ($expectedLogger) { $this->assertSame($expectedLogger, $context->getLogger()); }) ; $chainExtensions = new ChainExtension([$extension, new BreakCycleExtension(1)]); - $queueConsumer = new QueueConsumer($contextStub, $chainExtensions, 0); - $queueConsumer->bind(new NullQueue('aQueueName'), $processorMock); + $queueConsumer = new QueueConsumer($contextStub, $chainExtensions); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); + $queueConsumer->bind(new NullQueue('foo_queue'), $processorMock); $queueConsumer->consume(); } - public function testShouldCallEachQueueOneByOne() + public function testShouldCallProcessorAsMessageComeAlong() { - $expectedMessage = $this->createMessageMock(); - $messageConsumerStub = $this->createMessageConsumerStub($expectedMessage); + $queue1 = new NullQueue('foo_queue'); + $queue2 = new NullQueue('bar_queue'); + + $firstMessage = $this->createMessageMock(); + $secondMessage = $this->createMessageMock(); + $thirdMessage = $this->createMessageMock(); + + $subscriptionConsumerMock = new DummySubscriptionConsumer(); + $subscriptionConsumerMock->addMessage($firstMessage, 'foo_queue'); + $subscriptionConsumerMock->addMessage($secondMessage, 'bar_queue'); + $subscriptionConsumerMock->addMessage($thirdMessage, 'foo_queue'); - $contextStub = $this->createPsrContextStub($messageConsumerStub); + $fooConsumerStub = $this->createConsumerStub($queue1); + $barConsumerStub = $this->createConsumerStub($queue2); + + $consumers = [ + 'foo_queue' => $fooConsumerStub, + 'bar_queue' => $barConsumerStub, + ]; + + $contextStub = $this->createContextWithoutSubscriptionConsumerMock(); + $contextStub + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $queueName) { + return new NullQueue($queueName); + }) + ; + $contextStub + ->expects($this->any()) + ->method('createConsumer') + ->willReturnCallback(function (Queue $queue) use ($consumers) { + return $consumers[$queue->getQueueName()]; + }) + ; $processorMock = $this->createProcessorStub(); $anotherProcessorMock = $this->createProcessorStub(); - $queue1 = new NullQueue('aQueueName'); - $queue2 = new NullQueue('aAnotherQueueName'); + /** @var MessageReceived[] $actualContexts */ + $actualContexts = []; $extension = $this->createExtension(); $extension - ->expects($this->at(1)) - ->method('onBeforeReceive') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ($processorMock, $queue1) { - $this->assertSame($processorMock, $context->getPsrProcessor()); - $this->assertSame($queue1, $context->getPsrQueue()); - }) - ; - $extension - ->expects($this->at(4)) - ->method('onBeforeReceive') - ->with($this->isInstanceOf(Context::class)) - ->willReturnCallback(function (Context $context) use ($anotherProcessorMock, $queue2) { - $this->assertSame($anotherProcessorMock, $context->getPsrProcessor()); - $this->assertSame($queue2, $context->getPsrQueue()); + ->expects($this->any()) + ->method('onMessageReceived') + ->with($this->isInstanceOf(MessageReceived::class)) + ->willReturnCallback(function (MessageReceived $context) use (&$actualContexts) { + $actualContexts[] = clone $context; }) ; - $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(2), 0); + $queueConsumer = new QueueConsumer($contextStub, new BreakCycleExtension(3)); + $queueConsumer->setFallbackSubscriptionConsumer($subscriptionConsumerMock); $queueConsumer ->bind($queue1, $processorMock) ->bind($queue2, $anotherProcessorMock) ; $queueConsumer->consume(new ChainExtension([$extension])); + + $this->assertCount(3, $actualContexts); + + $this->assertSame($firstMessage, $actualContexts[0]->getMessage()); + $this->assertSame($secondMessage, $actualContexts[1]->getMessage()); + $this->assertSame($thirdMessage, $actualContexts[2]->getMessage()); + + $this->assertSame($fooConsumerStub, $actualContexts[0]->getConsumer()); + $this->assertSame($barConsumerStub, $actualContexts[1]->getConsumer()); + $this->assertSame($fooConsumerStub, $actualContexts[2]->getConsumer()); + } + + public function testCaptureExitStatus() + { + $testExitCode = 5; + + $stubExtension = $this->createExtension(); + + $stubExtension + ->expects($this->once()) + ->method('onStart') + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($testExitCode) { + $context->interruptExecution($testExitCode); + }) + ; + + $exitExtension = new ExitStatusExtension(); + + $consumer = new QueueConsumer($this->createContextStub(), $stubExtension); + $consumer->consume(new ChainExtension([$exitExtension])); + + $this->assertEquals($testExitCode, $exitExtension->getExitStatus()); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Consumer - * @param null|mixed $message + * @return \PHPUnit\Framework\MockObject\MockObject */ - protected function createMessageConsumerStub($message = null) + private function createContextWithoutSubscriptionConsumerMock(): InteropContext { - $messageConsumerMock = $this->createMock(Consumer::class); - $messageConsumerMock + $contextMock = $this->createMock(InteropContext::class); + $contextMock ->expects($this->any()) - ->method('receive') - ->willReturn($message) + ->method('createSubscriptionConsumer') + ->willThrowException(SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt()) ; - return $messageConsumerMock; + return $contextMock; } /** - * @return \PHPUnit_Framework_MockObject_MockObject|PsrContext - * @param null|mixed $messageConsumer + * @return \PHPUnit\Framework\MockObject\MockObject|InteropContext */ - protected function createPsrContextStub($messageConsumer = null) + private function createContextStub(?Consumer $consumer = null): InteropContext { - $context = $this->createMock(PsrContext::class); - $context - ->expects($this->any()) - ->method('createConsumer') - ->willReturn($messageConsumer) - ; + $context = $this->createContextWithoutSubscriptionConsumerMock(); $context ->expects($this->any()) ->method('createQueue') - ->willReturn($this->createMock(Queue::class)) + ->willReturnCallback(function (string $queueName) { + return new NullQueue($queueName); + }) ; $context ->expects($this->any()) - ->method('close') + ->method('createConsumer') + ->willReturnCallback(function (Queue $queue) use ($consumer) { + return $consumer ?: $this->createConsumerStub($queue); + }) ; return $context; } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor + * @return \PHPUnit\Framework\MockObject\MockObject|Processor */ - protected function createProcessorMock() + private function createProcessorMock() { return $this->createMock(Processor::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor + * @return \PHPUnit\Framework\MockObject\MockObject|Processor */ - protected function createProcessorStub() + private function createProcessorStub() { $processorMock = $this->createProcessorMock(); $processorMock @@ -1019,18 +1500,50 @@ protected function createProcessorStub() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Message + * @return \PHPUnit\Framework\MockObject\MockObject|Message */ - protected function createMessageMock() + private function createMessageMock(): Message { return $this->createMock(Message::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|ExtensionInterface + * @return \PHPUnit\Framework\MockObject\MockObject|ExtensionInterface */ - protected function createExtension() + private function createExtension() { return $this->createMock(ExtensionInterface::class); } + + /** + * @param mixed|null $queue + * + * @return \PHPUnit\Framework\MockObject\MockObject|Consumer + */ + private function createConsumerStub($queue = null): Consumer + { + if (null === $queue) { + $queue = 'queue'; + } + if (is_string($queue)) { + $queue = new NullQueue($queue); + } + + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue) + ; + + return $consumerMock; + } + + /** + * @return SubscriptionConsumer|\PHPUnit\Framework\MockObject\MockObject + */ + private function createSubscriptionConsumerMock(): SubscriptionConsumer + { + return $this->createMock(SubscriptionConsumer::class); + } } diff --git a/pkg/enqueue/Tests/Consumption/ResultTest.php b/pkg/enqueue/Tests/Consumption/ResultTest.php index 20b675593..26b8c7812 100644 --- a/pkg/enqueue/Tests/Consumption/ResultTest.php +++ b/pkg/enqueue/Tests/Consumption/ResultTest.php @@ -3,9 +3,10 @@ namespace Enqueue\Tests\Consumption; use Enqueue\Consumption\Result; -use Enqueue\Transport\Null\NullMessage; +use Enqueue\Null\NullMessage; +use PHPUnit\Framework\TestCase; -class ResultTest extends \PHPUnit_Framework_TestCase +class ResultTest extends TestCase { public function testCouldBeConstructedWithExpectedArguments() { @@ -13,13 +14,13 @@ public function testCouldBeConstructedWithExpectedArguments() $this->assertSame('theStatus', $result->getStatus()); $this->assertSame('', $result->getReason()); - $this->assertSame(null, $result->getReply()); + $this->assertNull($result->getReply()); $result = new Result('theStatus', 'theReason'); $this->assertSame('theStatus', $result->getStatus()); $this->assertSame('theReason', $result->getReason()); - $this->assertSame(null, $result->getReply()); + $this->assertNull($result->getReply()); } public function testCouldConstructedWithAckFactoryMethod() @@ -29,7 +30,7 @@ public function testCouldConstructedWithAckFactoryMethod() $this->assertInstanceOf(Result::class, $result); $this->assertSame(Result::ACK, $result->getStatus()); $this->assertSame('theReason', $result->getReason()); - $this->assertSame(null, $result->getReply()); + $this->assertNull($result->getReply()); } public function testCouldConstructedWithRejectFactoryMethod() @@ -39,7 +40,7 @@ public function testCouldConstructedWithRejectFactoryMethod() $this->assertInstanceOf(Result::class, $result); $this->assertSame(Result::REJECT, $result->getStatus()); $this->assertSame('theReason', $result->getReason()); - $this->assertSame(null, $result->getReply()); + $this->assertNull($result->getReply()); } public function testCouldConstructedWithRequeueFactoryMethod() @@ -49,14 +50,38 @@ public function testCouldConstructedWithRequeueFactoryMethod() $this->assertInstanceOf(Result::class, $result); $this->assertSame(Result::REQUEUE, $result->getStatus()); $this->assertSame('theReason', $result->getReason()); - $this->assertSame(null, $result->getReply()); + $this->assertNull($result->getReply()); } - public function testCouldConstructedWithReplyFactoryMethod() + public function testCouldConstructedWithReplyFactoryMethodAndAckStatusByDefault() { $reply = new NullMessage(); - $result = Result::reply($reply, 'theReason'); + $result = Result::reply($reply); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::ACK, $result->getStatus()); + $this->assertSame('', $result->getReason()); + $this->assertSame($reply, $result->getReply()); + } + + public function testCouldConstructedWithReplyFactoryMethodAndRejectStatusExplicitly() + { + $reply = new NullMessage(); + + $result = Result::reply($reply, Result::REJECT); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::REJECT, $result->getStatus()); + $this->assertSame('', $result->getReason()); + $this->assertSame($reply, $result->getReply()); + } + + public function testCouldConstructedWithReplyFactoryMethodAndReasonSet() + { + $reply = new NullMessage(); + + $result = Result::reply($reply, null, 'theReason'); $this->assertInstanceOf(Result::class, $result); $this->assertSame(Result::ACK, $result->getStatus()); diff --git a/pkg/enqueue/Tests/DoctrineConnectionFactoryFactoryTest.php b/pkg/enqueue/Tests/DoctrineConnectionFactoryFactoryTest.php new file mode 100644 index 000000000..14f7b1006 --- /dev/null +++ b/pkg/enqueue/Tests/DoctrineConnectionFactoryFactoryTest.php @@ -0,0 +1,71 @@ +registry = $this->prophesize(ManagerRegistry::class); + $this->fallbackFactory = $this->prophesize(ConnectionFactoryFactoryInterface::class); + + $this->factory = new DoctrineConnectionFactoryFactory($this->registry->reveal(), $this->fallbackFactory->reveal()); + } + + public function testCreateWithoutArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The config must be either array or DSN string.'); + + $this->factory->create(true); + } + + public function testCreateWithoutDsn() + { + $this->expectExceptionMessage(\InvalidArgumentException::class); + $this->expectExceptionMessage('The config must have dsn key set.'); + + $this->factory->create(['foo' => 'bar']); + } + + public function testCreateWithDoctrineSchema() + { + $this->assertInstanceOf( + ManagerRegistryConnectionFactory::class, + $this->factory->create('doctrine://localhost:3306') + ); + } + + public function testCreateFallback() + { + $this->fallbackFactory + ->create(['dsn' => 'fallback://']) + ->shouldBeCalled(); + + $this->factory->create(['dsn' => 'fallback://']); + } +} diff --git a/pkg/enqueue/Tests/Functional/Client/SimpleClientTest.php b/pkg/enqueue/Tests/Functional/Client/SimpleClientTest.php deleted file mode 100644 index eb84cf2bc..000000000 --- a/pkg/enqueue/Tests/Functional/Client/SimpleClientTest.php +++ /dev/null @@ -1,79 +0,0 @@ -context = $this->buildAmqpContext(); - - $this->removeQueue('default'); - } - - public function testProduceAndConsumeOneMessage() - { - $actualMessage = null; - - $client = new SimpleClient($this->context); - $client->bind('foo_topic', function (Message $message) use (&$actualMessage) { - $actualMessage = $message; - - return Result::ACK; - }); - - $client->send('foo_topic', 'Hello there!'); - - $client->consume(new ChainExtension([ - new LimitConsumptionTimeExtension(new \DateTime('+5sec')), - new LimitConsumedMessagesExtension(2), - ])); - - $this->assertInstanceOf(Message::class, $actualMessage); - $this->assertSame('Hello there!', $actualMessage->getBody()); - } - - public function testProduceAndRouteToTwoConsumes() - { - $received = 0; - - $client = new SimpleClient($this->context); - $client->bind('foo_topic', function () use (&$received) { - ++$received; - - return Result::ACK; - }); - $client->bind('foo_topic', function () use (&$received) { - ++$received; - - return Result::ACK; - }); - - $client->send('foo_topic', 'Hello there!'); - - $client->consume(new ChainExtension([ - new LimitConsumptionTimeExtension(new \DateTime('+5sec')), - new LimitConsumedMessagesExtension(3), - ])); - - $this->assertSame(2, $received); - } -} diff --git a/pkg/enqueue/Tests/Mocks/CustomPrepareBodyClientExtension.php b/pkg/enqueue/Tests/Mocks/CustomPrepareBodyClientExtension.php new file mode 100644 index 000000000..dd0e1a69e --- /dev/null +++ b/pkg/enqueue/Tests/Mocks/CustomPrepareBodyClientExtension.php @@ -0,0 +1,20 @@ +getMessage()->setBody('theCommandBodySerializedByCustomExtension'); + } + + public function onPreSendEvent(PreSend $context): void + { + $context->getMessage()->setBody('theEventBodySerializedByCustomExtension'); + } +} diff --git a/pkg/enqueue/Tests/Mocks/JsonSerializableObject.php b/pkg/enqueue/Tests/Mocks/JsonSerializableObject.php new file mode 100644 index 000000000..84885c316 --- /dev/null +++ b/pkg/enqueue/Tests/Mocks/JsonSerializableObject.php @@ -0,0 +1,12 @@ + 'fooVal']; + } +} diff --git a/pkg/enqueue/Tests/ResourcesTest.php b/pkg/enqueue/Tests/ResourcesTest.php new file mode 100644 index 000000000..ec713fd03 --- /dev/null +++ b/pkg/enqueue/Tests/ResourcesTest.php @@ -0,0 +1,149 @@ +assertTrue($rc->isFinal()); + } + + public function testShouldConstructorBePrivate() + { + $rc = new \ReflectionClass(Resources::class); + + $this->assertTrue($rc->getConstructor()->isPrivate()); + } + + public function testShouldGetAvailableConnectionsInExpectedFormat() + { + $availableConnections = Resources::getAvailableConnections(); + + self::assertIsArray($availableConnections); + $this->assertArrayHasKey(RedisConnectionFactory::class, $availableConnections); + + $connectionInfo = $availableConnections[RedisConnectionFactory::class]; + $this->assertArrayHasKey('schemes', $connectionInfo); + $this->assertSame(['redis', 'rediss'], $connectionInfo['schemes']); + + $this->assertArrayHasKey('supportedSchemeExtensions', $connectionInfo); + $this->assertSame(['predis', 'phpredis'], $connectionInfo['supportedSchemeExtensions']); + + $this->assertArrayHasKey('package', $connectionInfo); + $this->assertSame('enqueue/redis', $connectionInfo['package']); + } + + public function testShouldGetKnownConnectionsInExpectedFormat() + { + $availableConnections = Resources::getKnownConnections(); + + self::assertIsArray($availableConnections); + $this->assertArrayHasKey(RedisConnectionFactory::class, $availableConnections); + + $connectionInfo = $availableConnections[RedisConnectionFactory::class]; + $this->assertArrayHasKey('schemes', $connectionInfo); + $this->assertSame(['redis', 'rediss'], $connectionInfo['schemes']); + + $this->assertArrayHasKey('supportedSchemeExtensions', $connectionInfo); + $this->assertSame(['predis', 'phpredis'], $connectionInfo['supportedSchemeExtensions']); + + $this->assertArrayHasKey('package', $connectionInfo); + $this->assertSame('enqueue/redis', $connectionInfo['package']); + } + + public function testThrowsIfConnectionClassNotImplementConnectionFactoryInterfaceOnAddConnection() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The connection factory class "stdClass" must implement "Interop\Queue\ConnectionFactory" interface.'); + + Resources::addConnection(\stdClass::class, [], [], 'foo'); + } + + public function testThrowsIfNoSchemesProvidedOnAddConnection() + { + $connectionClass = $this->getMockClass(ConnectionFactory::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Schemes could not be empty.'); + + Resources::addConnection($connectionClass, [], [], 'foo'); + } + + public function testThrowsIfNoPackageProvidedOnAddConnection() + { + $connectionClass = $this->getMockClass(ConnectionFactory::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Package name could not be empty.'); + + Resources::addConnection($connectionClass, ['foo'], [], ''); + } + + public function testShouldAllowRegisterConnectionThatIsNotInstalled() + { + Resources::addConnection('theConnectionClass', ['foo'], [], 'foo'); + + $knownConnections = Resources::getKnownConnections(); + self::assertIsArray($knownConnections); + $this->assertArrayHasKey('theConnectionClass', $knownConnections); + + $availableConnections = Resources::getAvailableConnections(); + + self::assertIsArray($availableConnections); + $this->assertArrayNotHasKey('theConnectionClass', $availableConnections); + } + + public function testShouldAllowGetPreviouslyRegisteredConnection() + { + $connectionClass = $this->getMockClass(ConnectionFactory::class); + + Resources::addConnection( + $connectionClass, + ['fooscheme', 'barscheme'], + ['fooextension', 'barextension'], + 'foo/bar' + ); + + $availableConnections = Resources::getAvailableConnections(); + + self::assertIsArray($availableConnections); + $this->assertArrayHasKey($connectionClass, $availableConnections); + + $connectionInfo = $availableConnections[$connectionClass]; + $this->assertArrayHasKey('schemes', $connectionInfo); + $this->assertSame(['fooscheme', 'barscheme'], $connectionInfo['schemes']); + + $this->assertArrayHasKey('supportedSchemeExtensions', $connectionInfo); + $this->assertSame(['fooextension', 'barextension'], $connectionInfo['supportedSchemeExtensions']); + + $this->assertArrayHasKey('package', $connectionInfo); + $this->assertSame('foo/bar', $connectionInfo['package']); + } + + public function testShouldHaveRegisteredWampConfiguration() + { + $availableConnections = Resources::getKnownConnections(); + + self::assertIsArray($availableConnections); + $this->assertArrayHasKey(WampConnectionFactory::class, $availableConnections); + + $connectionInfo = $availableConnections[WampConnectionFactory::class]; + $this->assertArrayHasKey('schemes', $connectionInfo); + $this->assertSame(['wamp', 'ws'], $connectionInfo['schemes']); + + $this->assertArrayHasKey('supportedSchemeExtensions', $connectionInfo); + $this->assertSame([], $connectionInfo['supportedSchemeExtensions']); + + $this->assertArrayHasKey('package', $connectionInfo); + $this->assertSame('enqueue/wamp', $connectionInfo['package']); + } +} diff --git a/pkg/enqueue/Tests/Router/RecipientTest.php b/pkg/enqueue/Tests/Router/RecipientTest.php index db893fee2..57cc83eed 100644 --- a/pkg/enqueue/Tests/Router/RecipientTest.php +++ b/pkg/enqueue/Tests/Router/RecipientTest.php @@ -2,15 +2,16 @@ namespace Enqueue\Tests\Router; -use Enqueue\Psr\Destination; -use Enqueue\Psr\Message; use Enqueue\Router\Recipient; +use Interop\Queue\Destination; +use Interop\Queue\Message as InteropMessage; +use PHPUnit\Framework\TestCase; -class RecipientTest extends \PHPUnit_Framework_TestCase +class RecipientTest extends TestCase { public function testShouldAllowGetMessageSetInConstructor() { - $message = $this->createMock(Message::class); + $message = $this->createMock(InteropMessage::class); $recipient = new Recipient($this->createMock(Destination::class), $message); @@ -21,7 +22,7 @@ public function testShouldAllowGetDestinationSetInConstructor() { $destination = $this->createMock(Destination::class); - $recipient = new Recipient($destination, $this->createMock(Message::class)); + $recipient = new Recipient($destination, $this->createMock(InteropMessage::class)); $this->assertSame($destination, $recipient->getDestination()); } diff --git a/pkg/enqueue/Tests/Router/RouteRecipientListProcessorTest.php b/pkg/enqueue/Tests/Router/RouteRecipientListProcessorTest.php index 780470cad..ae878fc53 100644 --- a/pkg/enqueue/Tests/Router/RouteRecipientListProcessorTest.php +++ b/pkg/enqueue/Tests/Router/RouteRecipientListProcessorTest.php @@ -3,17 +3,18 @@ namespace Enqueue\Tests\Router; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context; -use Enqueue\Psr\Processor; -use Enqueue\Psr\Producer; +use Enqueue\Null\NullMessage; +use Enqueue\Null\NullQueue; use Enqueue\Router\Recipient; use Enqueue\Router\RecipientListRouterInterface; use Enqueue\Router\RouteRecipientListProcessor; use Enqueue\Test\ClassExtensionTrait; -use Enqueue\Transport\Null\NullMessage; -use Enqueue\Transport\Null\NullQueue; +use Interop\Queue\Context; +use Interop\Queue\Processor; +use Interop\Queue\Producer as InteropProducer; +use PHPUnit\Framework\TestCase; -class RouteRecipientListProcessorTest extends \PHPUnit_Framework_TestCase +class RouteRecipientListProcessorTest extends TestCase { use ClassExtensionTrait; @@ -22,11 +23,6 @@ public function testShouldImplementProcessorInterface() $this->assertClassImplements(Processor::class, RouteRecipientListProcessor::class); } - public function testCouldBeConstructedWithRouterAsFirstArgument() - { - new RouteRecipientListProcessor($this->createRecipientListRouterMock()); - } - public function testShouldProduceRecipientsMessagesAndAckOriginalMessage() { $fooRecipient = new Recipient(new NullQueue('aName'), new NullMessage()); @@ -54,7 +50,7 @@ public function testShouldProduceRecipientsMessagesAndAckOriginalMessage() ->with($this->identicalTo($barRecipient->getDestination()), $this->identicalTo($barRecipient->getMessage())) ; - $sessionMock = $this->createPsrContextMock(); + $sessionMock = $this->createContextMock(); $sessionMock ->expects($this->once()) ->method('createProducer') @@ -69,23 +65,23 @@ public function testShouldProduceRecipientsMessagesAndAckOriginalMessage() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Producer + * @return \PHPUnit\Framework\MockObject\MockObject|InteropProducer */ protected function createProducerMock() { - return $this->createMock(Producer::class); + return $this->createMock(InteropProducer::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context + * @return \PHPUnit\Framework\MockObject\MockObject|Context */ - protected function createPsrContextMock() + protected function createContextMock() { return $this->createMock(Context::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|RecipientListRouterInterface + * @return \PHPUnit\Framework\MockObject\MockObject|RecipientListRouterInterface */ protected function createRecipientListRouterMock() { diff --git a/pkg/enqueue/Tests/Rpc/PromiseTest.php b/pkg/enqueue/Tests/Rpc/PromiseTest.php index 1e7057b1a..6762149ef 100644 --- a/pkg/enqueue/Tests/Rpc/PromiseTest.php +++ b/pkg/enqueue/Tests/Rpc/PromiseTest.php @@ -2,139 +2,236 @@ namespace Enqueue\Tests\Rpc; -use Enqueue\Psr\Consumer; +use Enqueue\Null\NullMessage; use Enqueue\Rpc\Promise; -use Enqueue\Transport\Null\NullMessage; +use PHPUnit\Framework\TestCase; -class PromiseTest extends \PHPUnit_Framework_TestCase +class PromiseTest extends TestCase { - public function testCouldBeConstructedWithExpectedSetOfArguments() + public function testIsDeleteReplyQueueShouldReturnTrueByDefault() { - new Promise($this->createPsrConsumerMock(), 'aCorrelationId', 2); + $promise = new Promise(function () {}, function () {}, function () {}); + + $this->assertTrue($promise->isDeleteReplyQueue()); + } + + public function testCouldSetGetDeleteReplyQueue() + { + $promise = new Promise(function () {}, function () {}, function () {}); + + $promise->setDeleteReplyQueue(false); + $this->assertFalse($promise->isDeleteReplyQueue()); + + $promise->setDeleteReplyQueue(true); + $this->assertTrue($promise->isDeleteReplyQueue()); + } + + public function testOnReceiveShouldCallReceiveCallBack() + { + $receiveInvoked = false; + $receivePromise = null; + $receiveTimeout = null; + $receivecb = function ($promise, $timeout) use (&$receiveInvoked, &$receivePromise, &$receiveTimeout) { + $receiveInvoked = true; + $receivePromise = $promise; + $receiveTimeout = $timeout; + }; + + $promise = new Promise($receivecb, function () {}, function () {}); + $promise->receive(); + + $this->assertTrue($receiveInvoked); + $this->assertInstanceOf(Promise::class, $receivePromise); + $this->assertNull($receiveTimeout); } - public function testShouldTimeoutIfNoResponseMessage() + public function testOnReceiveShouldCallReceiveCallBackWithTimeout() { - $psrConsumerMock = $this->createPsrConsumerMock(); - $psrConsumerMock - ->expects($this->atLeastOnce()) - ->method('receive') - ->willReturn(null) - ; - - $promise = new Promise($psrConsumerMock, 'aCorrelationId', 2); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Time outed without receiving reply message. Timeout: 2, CorrelationId: aCorrelationId'); - $promise->getMessage(); + $receiveInvoked = false; + $receivePromise = null; + $receiveTimeout = null; + $receivecb = function ($promise, $timeout) use (&$receiveInvoked, &$receivePromise, &$receiveTimeout) { + $receiveInvoked = true; + $receivePromise = $promise; + $receiveTimeout = $timeout; + }; + + $promise = new Promise($receivecb, function () {}, function () {}); + $promise->receive(12345); + + $this->assertTrue($receiveInvoked); + $this->assertInstanceOf(Promise::class, $receivePromise); + $this->assertSame(12345, $receiveTimeout); } - public function testShouldReturnReplyMessageIfCorrelationIdSame() + public function testOnReceiveNoWaitShouldCallReceiveNoWaitCallBack() { - $correlationId = 'theCorrelationId'; - - $replyMessage = new NullMessage(); - $replyMessage->setCorrelationId($correlationId); - - $psrConsumerMock = $this->createPsrConsumerMock(); - $psrConsumerMock - ->expects($this->once()) - ->method('receive') - ->willReturn($replyMessage) - ; - $psrConsumerMock - ->expects($this->once()) - ->method('acknowledge') - ->with($this->identicalTo($replyMessage)) - ; - - $promise = new Promise($psrConsumerMock, $correlationId, 2); - - $actualReplyMessage = $promise->getMessage(); - $this->assertSame($replyMessage, $actualReplyMessage); + $receiveInvoked = false; + $receivecb = function () use (&$receiveInvoked) { + $receiveInvoked = true; + }; + + $promise = new Promise(function () {}, $receivecb, function () {}); + $promise->receiveNoWait(); + + $this->assertTrue($receiveInvoked); } - public function testShouldReQueueIfCorrelationIdNotSame() + public function testOnReceiveShouldCallFinallyCallback() { - $correlationId = 'theCorrelationId'; - - $anotherReplyMessage = new NullMessage(); - $anotherReplyMessage->setCorrelationId('theOtherCorrelationId'); - - $replyMessage = new NullMessage(); - $replyMessage->setCorrelationId($correlationId); - - $psrConsumerMock = $this->createPsrConsumerMock(); - $psrConsumerMock - ->expects($this->at(0)) - ->method('receive') - ->willReturn($anotherReplyMessage) - ; - $psrConsumerMock - ->expects($this->at(1)) - ->method('reject') - ->with($this->identicalTo($anotherReplyMessage), true) - ; - $psrConsumerMock - ->expects($this->at(2)) - ->method('receive') - ->willReturn($replyMessage) - ; - $psrConsumerMock - ->expects($this->at(3)) - ->method('acknowledge') - ->with($this->identicalTo($replyMessage)) - ; - - $promise = new Promise($psrConsumerMock, $correlationId, 2); - - $actualReplyMessage = $promise->getMessage(); - $this->assertSame($replyMessage, $actualReplyMessage); + $invoked = false; + $cb = function () use (&$invoked) { + $invoked = true; + }; + + $promise = new Promise(function () {}, function () {}, $cb); + $promise->receive(); + + $this->assertTrue($invoked); } - public function testShouldTrySeveralTimesToReceiveReplyMessage() + public function testOnReceiveShouldCallFinallyCallbackEvenIfExceptionThrown() { - $correlationId = 'theCorrelationId'; - - $anotherReplyMessage = new NullMessage(); - $anotherReplyMessage->setCorrelationId('theOtherCorrelationId'); - - $replyMessage = new NullMessage(); - $replyMessage->setCorrelationId($correlationId); - - $psrConsumerMock = $this->createPsrConsumerMock(); - $psrConsumerMock - ->expects($this->at(0)) - ->method('receive') - ->willReturn(null) - ; - $psrConsumerMock - ->expects($this->at(1)) - ->method('receive') - ->willReturn(null) - ; - $psrConsumerMock - ->expects($this->at(2)) - ->method('receive') - ->willReturn($replyMessage) - ; - $psrConsumerMock - ->expects($this->at(3)) - ->method('acknowledge') - ->with($this->identicalTo($replyMessage)) - ; - - $promise = new Promise($psrConsumerMock, $correlationId, 2); - - $actualReplyMessage = $promise->getMessage(); - $this->assertSame($replyMessage, $actualReplyMessage); + $invokedFinally = false; + $finallycb = function () use (&$invokedFinally) { + $invokedFinally = true; + }; + + $invokedReceive = false; + $receivecb = function () use (&$invokedReceive) { + $invokedReceive = true; + throw new \Exception(); + }; + + try { + $promise = new Promise($receivecb, function () {}, $finallycb); + $promise->receive(); + } catch (\Exception $e) { + } + + $this->assertTrue($invokedReceive); + $this->assertTrue($invokedFinally); } - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Consumer - */ - private function createPsrConsumerMock() + public function testOnReceiveShouldThrowExceptionIfCallbackReturnNotMessageInstance() { - return $this->createMock(Consumer::class); + $receivecb = function () { + return new \stdClass(); + }; + + $promise = new Promise($receivecb, function () {}, function () {}); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Expected "Interop\Queue\Message" but got: "stdClass"'); + + $promise->receive(); + } + + public function testOnReceiveNoWaitShouldThrowExceptionIfCallbackReturnNotMessageInstance() + { + $receivecb = function () { + return new \stdClass(); + }; + + $promise = new Promise(function () {}, $receivecb, function () {}); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Expected "Interop\Queue\Message" but got: "stdClass"'); + + $promise->receiveNoWait(); + } + + public function testOnReceiveNoWaitShouldCallFinallyCallbackOnlyIfMessageReceived() + { + $invokedReceive = false; + $receivecb = function () use (&$invokedReceive) { + $invokedReceive = true; + }; + + $invokedFinally = false; + $finallycb = function () use (&$invokedFinally) { + $invokedFinally = true; + }; + + $promise = new Promise(function () {}, $receivecb, $finallycb); + $promise->receiveNoWait(); + + $this->assertTrue($invokedReceive); + $this->assertFalse($invokedFinally); + + // now should call finally too + + $invokedReceive = false; + $receivecb = function () use (&$invokedReceive) { + $invokedReceive = true; + + return new NullMessage(); + }; + + $promise = new Promise(function () {}, $receivecb, $finallycb); + $promise->receiveNoWait(); + + $this->assertTrue($invokedReceive); + $this->assertTrue($invokedFinally); + } + + public function testOnReceiveShouldNotCallCallbackIfMessageReceivedByReceiveNoWaitBefore() + { + $message = new NullMessage(); + + $invokedReceive = false; + $receivecb = function () use (&$invokedReceive) { + $invokedReceive = true; + }; + + $invokedReceiveNoWait = false; + $receiveNoWaitCb = function () use (&$invokedReceiveNoWait, $message) { + $invokedReceiveNoWait = true; + + return $message; + }; + + $promise = new Promise($receivecb, $receiveNoWaitCb, function () {}); + + $this->assertSame($message, $promise->receiveNoWait()); + $this->assertTrue($invokedReceiveNoWait); + $this->assertFalse($invokedReceive); + + // receive should return message but not call callback + $invokedReceiveNoWait = false; + + $this->assertSame($message, $promise->receive()); + $this->assertFalse($invokedReceiveNoWait); + $this->assertFalse($invokedReceive); + } + + public function testOnReceiveNoWaitShouldNotCallCallbackIfMessageReceivedByReceiveBefore() + { + $message = new NullMessage(); + + $invokedReceive = false; + $receivecb = function () use (&$invokedReceive, $message) { + $invokedReceive = true; + + return $message; + }; + + $invokedReceiveNoWait = false; + $receiveNoWaitCb = function () use (&$invokedReceiveNoWait) { + $invokedReceiveNoWait = true; + }; + + $promise = new Promise($receivecb, $receiveNoWaitCb, function () {}); + + $this->assertSame($message, $promise->receive()); + $this->assertTrue($invokedReceive); + $this->assertFalse($invokedReceiveNoWait); + + // receiveNoWait should return message but not call callback + $invokedReceive = false; + + $this->assertSame($message, $promise->receiveNoWait()); + $this->assertFalse($invokedReceiveNoWait); + $this->assertFalse($invokedReceive); } } diff --git a/pkg/enqueue/Tests/Rpc/RpcClientTest.php b/pkg/enqueue/Tests/Rpc/RpcClientTest.php index 4e35c32ba..db4813c88 100644 --- a/pkg/enqueue/Tests/Rpc/RpcClientTest.php +++ b/pkg/enqueue/Tests/Rpc/RpcClientTest.php @@ -2,22 +2,19 @@ namespace Enqueue\Tests\Rpc; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\Context; -use Enqueue\Psr\Producer; +use Enqueue\Null\NullContext; +use Enqueue\Null\NullMessage; +use Enqueue\Null\NullQueue; use Enqueue\Rpc\Promise; use Enqueue\Rpc\RpcClient; -use Enqueue\Transport\Null\NullContext; -use Enqueue\Transport\Null\NullMessage; -use Enqueue\Transport\Null\NullQueue; +use Interop\Queue\Consumer; +use Interop\Queue\Context; +use Interop\Queue\Producer as InteropProducer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; -class RpcClientTest extends \PHPUnit_Framework_TestCase +class RpcClientTest extends TestCase { - public function testCouldBeConstructedWithPsrContextAsFirstArgument() - { - new RpcClient($this->createPsrContextMock()); - } - public function testShouldSetReplyToIfNotSet() { $context = new NullContext(); @@ -72,28 +69,190 @@ public function testShouldNotSetCorrelationIdIfSet() $this->assertEquals('theCorrelationId', $message->getCorrelationId()); } - public function testShouldPopulatePromiseWithExpectedArguments() + public function testShouldProduceMessageToQueue() { - $context = new NullContext(); + $queue = new NullQueue('aQueue'); + $message = new NullMessage(); + $message->setCorrelationId('theCorrelationId'); + $message->setReplyTo('theReplyTo'); - $queue = $context->createQueue('rpc.call'); - $message = $context->createMessage(); + $producer = $this->createInteropProducerMock(); + $producer + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($message)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producer) + ; + + $rpc = new RpcClient($context); + + $rpc->callAsync($queue, $message, 2); + } + + public function testShouldReceiveMessageAndAckMessageIfCorrelationEquals() + { + $queue = new NullQueue('aQueue'); + $replyQueue = new NullQueue('theReplyTo'); + $message = new NullMessage(); $message->setCorrelationId('theCorrelationId'); $message->setReplyTo('theReplyTo'); - $timeout = 123; + $receivedMessage = new NullMessage(); + $receivedMessage->setCorrelationId('theCorrelationId'); + + $consumer = $this->createConsumerMock(); + $consumer + ->expects($this->once()) + ->method('receive') + ->with(12345) + ->willReturn($receivedMessage) + ; + $consumer + ->expects($this->once()) + ->method('acknowledge') + ->with($this->identicalTo($receivedMessage)) + ; + $consumer + ->expects($this->never()) + ->method('reject') + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($this->createInteropProducerMock()) + ; + $context + ->expects($this->atLeastOnce()) + ->method('createQueue') + ->with('theReplyTo') + ->willReturn($replyQueue) + ; + $context + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($replyQueue)) + ->willReturn($consumer) + ; + + $rpc = new RpcClient($context); + + $rpc->callAsync($queue, $message, 2)->receive(12345); + } + + public function testShouldReceiveNoWaitMessageAndAckMessageIfCorrelationEquals() + { + $queue = new NullQueue('aQueue'); + $replyQueue = new NullQueue('theReplyTo'); + $message = new NullMessage(); + $message->setCorrelationId('theCorrelationId'); + $message->setReplyTo('theReplyTo'); + + $receivedMessage = new NullMessage(); + $receivedMessage->setCorrelationId('theCorrelationId'); + + $consumer = $this->createConsumerMock(); + $consumer + ->expects($this->once()) + ->method('receiveNoWait') + ->willReturn($receivedMessage) + ; + $consumer + ->expects($this->once()) + ->method('acknowledge') + ->with($this->identicalTo($receivedMessage)) + ; + $consumer + ->expects($this->never()) + ->method('reject') + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($this->createInteropProducerMock()) + ; + $context + ->expects($this->atLeastOnce()) + ->method('createQueue') + ->with('theReplyTo') + ->willReturn($replyQueue) + ; + $context + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($replyQueue)) + ->willReturn($consumer) + ; $rpc = new RpcClient($context); - $promise = $rpc->callAsync($queue, $message, $timeout); + $rpc->callAsync($queue, $message, 2)->receiveNoWait(); + } + + public function testShouldDeleteQueueAfterReceiveIfDeleteReplyQueueIsTrue() + { + $queue = new NullQueue('aQueue'); + $replyQueue = new NullQueue('theReplyTo'); + $message = new NullMessage(); + $message->setCorrelationId('theCorrelationId'); + $message->setReplyTo('theReplyTo'); + + $receivedMessage = new NullMessage(); + $receivedMessage->setCorrelationId('theCorrelationId'); + + $consumer = $this->createConsumerMock(); + $consumer + ->expects($this->once()) + ->method('receive') + ->willReturn($receivedMessage) + ; + + $context = $this->getMockBuilder(Context::class) + ->disableOriginalConstructor() + ->setMethods(['deleteQueue']) + ->getMockForAbstractClass() + ; + + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($this->createInteropProducerMock()) + ; + $context + ->expects($this->atLeastOnce()) + ->method('createQueue') + ->with('theReplyTo') + ->willReturn($replyQueue) + ; + $context + ->expects($this->once()) + ->method('createConsumer') + ->with($this->identicalTo($replyQueue)) + ->willReturn($consumer) + ; + $context + ->expects($this->once()) + ->method('deleteQueue') + ->with($this->identicalTo($replyQueue)) + ; + + $rpc = new RpcClient($context); - $this->assertInstanceOf(Promise::class, $promise); - $this->assertAttributeEquals('theCorrelationId', 'correlationId', $promise); - $this->assertAttributeEquals(123, 'timeout', $promise); - $this->assertAttributeInstanceOf(Consumer::class, 'consumer', $promise); + $promise = $rpc->callAsync($queue, $message, 2); + $promise->setDeleteReplyQueue(true); + $promise->receive(); } - public function testShouldProduceMessageToQueueAndCreateConsumerForReplyQueue() + public function testShouldNotCallDeleteQueueIfDeleteReplyQueueIsTrueButContextHasNoDeleteQueueMethod() { $queue = new NullQueue('aQueue'); $replyQueue = new NullQueue('theReplyTo'); @@ -101,18 +260,21 @@ public function testShouldProduceMessageToQueueAndCreateConsumerForReplyQueue() $message->setCorrelationId('theCorrelationId'); $message->setReplyTo('theReplyTo'); - $producer = $this->createPsrProducerMock(); - $producer + $receivedMessage = new NullMessage(); + $receivedMessage->setCorrelationId('theCorrelationId'); + + $consumer = $this->createConsumerMock(); + $consumer ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($queue), $this->identicalTo($message)) + ->method('receive') + ->willReturn($receivedMessage) ; - $context = $this->createPsrContextMock(); + $context = $this->createContextMock(); $context ->expects($this->once()) ->method('createProducer') - ->willReturn($producer) + ->willReturn($this->createInteropProducerMock()) ; $context ->expects($this->once()) @@ -124,12 +286,15 @@ public function testShouldProduceMessageToQueueAndCreateConsumerForReplyQueue() ->expects($this->once()) ->method('createConsumer') ->with($this->identicalTo($replyQueue)) - ->willReturn($this->createPsrConsumerMock()) + ->willReturn($consumer) ; $rpc = new RpcClient($context); - $rpc->callAsync($queue, $message, 2); + $promise = $rpc->callAsync($queue, $message, 2); + $promise->setDeleteReplyQueue(true); + + $promise->receive(); } public function testShouldDoSyncCall() @@ -142,7 +307,7 @@ public function testShouldDoSyncCall() $promiseMock = $this->createMock(Promise::class); $promiseMock ->expects($this->once()) - ->method('getMessage') + ->method('receive') ->willReturn($replyMessage) ; @@ -160,25 +325,25 @@ public function testShouldDoSyncCall() } /** - * @return Context|\PHPUnit_Framework_MockObject_MockObject|Producer + * @return Context|MockObject|InteropProducer */ - private function createPsrProducerMock() + private function createInteropProducerMock() { - return $this->createMock(Producer::class); + return $this->createMock(InteropProducer::class); } /** - * @return \Enqueue\Psr\Context|\PHPUnit_Framework_MockObject_MockObject|Consumer + * @return MockObject|Consumer */ - private function createPsrConsumerMock() + private function createConsumerMock() { return $this->createMock(Consumer::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context + * @return MockObject|Context */ - private function createPsrContextMock() + private function createContextMock() { return $this->createMock(Context::class); } diff --git a/pkg/enqueue/Tests/Rpc/TimeoutExceptionTest.php b/pkg/enqueue/Tests/Rpc/TimeoutExceptionTest.php new file mode 100644 index 000000000..ef43a5e75 --- /dev/null +++ b/pkg/enqueue/Tests/Rpc/TimeoutExceptionTest.php @@ -0,0 +1,24 @@ +assertTrue($rc->isSubclassOf(\LogicException::class)); + } + + public function testShouldCreateSelfInstanceWithPreSetMessage() + { + $exception = TimeoutException::create('theTimeout', 'theCorrelationId'); + + $this->assertInstanceOf(TimeoutException::class, $exception); + $this->assertEquals('Rpc call timeout is reached without receiving a reply message. Timeout: theTimeout, CorrelationId: theCorrelationId', $exception->getMessage()); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/ConsumeCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/ConsumeCommandTest.php new file mode 100644 index 000000000..3758ca96a --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/ConsumeCommandTest.php @@ -0,0 +1,703 @@ +assertClassExtends(Command::class, ConsumeCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(ConsumeCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = ConsumeCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); + + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:consume', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); + } + + public function testShouldHaveExpectedOptions() + { + $command = new ConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(9, $options); + $this->assertArrayHasKey('memory-limit', $options); + $this->assertArrayHasKey('message-limit', $options); + $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('receive-timeout', $options); + $this->assertArrayHasKey('niceness', $options); + $this->assertArrayHasKey('client', $options); + $this->assertArrayHasKey('logger', $options); + $this->assertArrayHasKey('skip', $options); + $this->assertArrayHasKey('setup-broker', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new ConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(1, $arguments); + $this->assertArrayHasKey('client-queue-names', $arguments); + } + + public function testShouldBindDefaultQueueOnly() + { + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([]); + + $processor = $this->createDelegateProcessorMock(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->once()) + ->method('createQueue') + ->with('default', true) + ->willReturn($queue) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldUseRequestedClient() + { + $defaultProcessor = $this->createDelegateProcessorMock(); + + $defaultConsumer = $this->createQueueConsumerMock(); + $defaultConsumer + ->expects($this->never()) + ->method('bind') + ; + $defaultConsumer + ->expects($this->never()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $defaultDriver = $this->createDriverStub(new RouteCollection([])); + $defaultDriver + ->expects($this->never()) + ->method('createQueue') + ; + + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([]); + + $fooProcessor = $this->createDelegateProcessorMock(); + + $fooConsumer = $this->createQueueConsumerMock(); + $fooConsumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($fooProcessor)) + ; + $fooConsumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $fooDriver = $this->createDriverStub($routeCollection); + $fooDriver + ->expects($this->once()) + ->method('createQueue') + ->with('default', true) + ->willReturn($queue) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $defaultConsumer, + 'enqueue.client.default.driver' => $defaultDriver, + 'enqueue.client.default.delegate_processor' => $defaultProcessor, + 'enqueue.client.foo.queue_consumer' => $fooConsumer, + 'enqueue.client.foo.driver' => $fooDriver, + 'enqueue.client.foo.delegate_processor' => $fooProcessor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + '--client' => 'foo', + ]); + } + + public function testThrowIfNotDefinedClientRequested() + { + $routeCollection = new RouteCollection([]); + + $processor = $this->createDelegateProcessorMock(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->never()) + ->method('bind') + ; + $consumer + ->expects($this->never()) + ->method('consume') + ; + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->never()) + ->method('createQueue') + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Client "not-defined" is not supported.'); + $tester->execute([ + '--client' => 'not-defined', + ]); + } + + public function testShouldBindDefaultQueueIfRouteUseDifferentQueue() + { + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor'), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->once()) + ->method('createQueue') + ->with('default', true) + ->willReturn($queue) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldBindCustomExecuteConsumptionAndUseCustomClientDestinationName() + { + $defaultQueue = new NullQueue(''); + $customQueue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor', ['queue' => 'custom']), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->at(3)) + ->method('createQueue') + ->with('default', true) + ->willReturn($defaultQueue) + ; + $driver + ->expects($this->at(4)) + ->method('createQueue') + ->with('custom', true) + ->willReturn($customQueue) + ; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->at(0)) + ->method('bind') + ->with($this->identicalTo($defaultQueue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(1)) + ->method('bind') + ->with($this->identicalTo($customQueue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(2)) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldBindUserProvidedQueues() + { + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor', ['queue' => 'custom']), + new Route('topic', Route::TOPIC, 'processor', ['queue' => 'non-default-queue']), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->once()) + ->method('createQueue') + ->with('non-default-queue', true) + ->willReturn($queue) + ; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'client-queue-names' => ['non-default-queue'], + ]); + } + + public function testShouldBindNotPrefixedQueue() + { + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('topic', Route::TOPIC, 'processor', ['queue' => 'non-prefixed-queue', 'prefix_queue' => false]), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->once()) + ->method('createQueue') + ->with('non-prefixed-queue', false) + ->willReturn($queue) + ; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'client-queue-names' => ['non-prefixed-queue'], + ]); + } + + public function testShouldBindQueuesOnlyOnce() + { + $defaultQueue = new NullQueue(''); + $customQueue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('fooTopic', Route::TOPIC, 'processor', ['queue' => 'custom']), + new Route('barTopic', Route::TOPIC, 'processor', ['queue' => 'custom']), + new Route('ololoTopic', Route::TOPIC, 'processor', []), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->at(3)) + ->method('createQueue') + ->with('default', true) + ->willReturn($defaultQueue) + ; + $driver + ->expects($this->at(4)) + ->method('createQueue', true) + ->with('custom') + ->willReturn($customQueue) + ; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->at(0)) + ->method('bind') + ->with($this->identicalTo($defaultQueue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(1)) + ->method('bind') + ->with($this->identicalTo($customQueue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(2)) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldNotBindExternalRoutes() + { + $defaultQueue = new NullQueue(''); + + $routeCollection = new RouteCollection([ + new Route('barTopic', Route::TOPIC, 'processor', ['queue' => null]), + new Route('fooTopic', Route::TOPIC, 'processor', ['queue' => 'external_queue', 'external' => true]), + ]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->exactly(1)) + ->method('createQueue') + ->with('default', true) + ->willReturn($defaultQueue) + ; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->exactly(1)) + ->method('bind') + ->with($this->identicalTo($defaultQueue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(1)) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldSkipQueueConsumptionAndUseCustomClientDestinationName() + { + $queue = new NullQueue(''); + + $processor = $this->createDelegateProcessorMock(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->exactly(3)) + ->method('bind') + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $routeCollection = new RouteCollection([ + new Route('fooTopic', Route::TOPIC, 'processor', ['queue' => 'fooQueue']), + new Route('barTopic', Route::TOPIC, 'processor', ['queue' => 'barQueue']), + new Route('ololoTopic', Route::TOPIC, 'processor', ['queue' => 'ololoQueue']), + ]); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->at(3)) + ->method('createQueue', true) + ->with('default') + ->willReturn($queue) + ; + $driver + ->expects($this->at(4)) + ->method('createQueue', true) + ->with('fooQueue') + ->willReturn($queue) + ; + $driver + ->expects($this->at(5)) + ->method('createQueue', true) + ->with('ololoQueue') + ->willReturn($queue) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + '--skip' => ['barQueue'], + ]); + } + + public function testShouldReturnExitStatusIfSet() + { + $testExitCode = 678; + + $stubExtension = $this->createExtension(); + + $stubExtension + ->expects($this->once()) + ->method('onStart') + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($testExitCode) { + $context->interruptExecution($testExitCode); + }) + ; + + $defaultQueue = new NullQueue('default'); + + $routeCollection = new RouteCollection([]); + + $processor = $this->createDelegateProcessorMock(); + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->exactly(1)) + ->method('createQueue') + ->with('default', true) + ->willReturn($defaultQueue) + ; + + $consumer = new QueueConsumer($this->createContextStub(), $stubExtension); + + $command = new ConsumeCommand(new Container([ + 'enqueue.client.default.queue_consumer' => $consumer, + 'enqueue.client.default.driver' => $driver, + 'enqueue.client.default.delegate_processor' => $processor, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertEquals($testExitCode, $tester->getStatusCode()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DelegateProcessor + */ + private function createDelegateProcessorMock() + { + return $this->createMock(DelegateProcessor::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + private function createQueueConsumerMock() + { + return $this->createMock(QueueConsumerInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface + */ + private function createDriverStub(?RouteCollection $routeCollection = null): DriverInterface + { + $driverMock = $this->createMock(DriverInterface::class); + $driverMock + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection ?? new RouteCollection([])) + ; + + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn(Config::create('aPrefix', 'anApp')) + ; + + return $driverMock; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createContextWithoutSubscriptionConsumerMock(): InteropContext + { + $contextMock = $this->createMock(InteropContext::class); + $contextMock + ->expects($this->any()) + ->method('createSubscriptionConsumer') + ->willThrowException(SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt()) + ; + + return $contextMock; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|InteropContext + */ + private function createContextStub(?Consumer $consumer = null): InteropContext + { + $context = $this->createContextWithoutSubscriptionConsumerMock(); + $context + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $queueName) { + return new NullQueue($queueName); + }) + ; + $context + ->expects($this->any()) + ->method('createConsumer') + ->willReturnCallback(function (Queue $queue) use ($consumer) { + return $consumer ?: $this->createConsumerStub($queue); + }) + ; + + return $context; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ExtensionInterface + */ + private function createExtension() + { + return $this->createMock(ExtensionInterface::class); + } + + /** + * @param mixed|null $queue + * + * @return \PHPUnit\Framework\MockObject\MockObject|Consumer + */ + private function createConsumerStub($queue = null): Consumer + { + if (null === $queue) { + $queue = 'queue'; + } + if (is_string($queue)) { + $queue = new NullQueue($queue); + } + + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue) + ; + + return $consumerMock; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/ConsumeMessagesCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/ConsumeMessagesCommandTest.php deleted file mode 100644 index 2247a6478..000000000 --- a/pkg/enqueue/Tests/Symfony/Client/ConsumeMessagesCommandTest.php +++ /dev/null @@ -1,231 +0,0 @@ -createQueueConsumerMock(), - $this->createDelegateProcessorMock(), - $this->createQueueMetaRegistry([]), - $this->createDriverMock() - ); - } - - public function testShouldHaveCommandName() - { - $command = new ConsumeMessagesCommand( - $this->createQueueConsumerMock(), - $this->createDelegateProcessorMock(), - $this->createQueueMetaRegistry([]), - $this->createDriverMock() - ); - - $this->assertEquals('enqueue:consume', $command->getName()); - } - - public function testShouldHaveCommandAliases() - { - $command = new ConsumeMessagesCommand( - $this->createQueueConsumerMock(), - $this->createDelegateProcessorMock(), - $this->createQueueMetaRegistry([]), - $this->createDriverMock() - ); - - $this->assertEquals(['enq:c'], $command->getAliases()); - } - - public function testShouldHaveExpectedOptions() - { - $command = new ConsumeMessagesCommand( - $this->createQueueConsumerMock(), - $this->createDelegateProcessorMock(), - $this->createQueueMetaRegistry([]), - $this->createDriverMock() - ); - - $options = $command->getDefinition()->getOptions(); - - $this->assertCount(4, $options); - $this->assertArrayHasKey('memory-limit', $options); - $this->assertArrayHasKey('message-limit', $options); - $this->assertArrayHasKey('time-limit', $options); - $this->assertArrayHasKey('setup-broker', $options); - } - - public function testShouldHaveExpectedAttributes() - { - $command = new ConsumeMessagesCommand( - $this->createQueueConsumerMock(), - $this->createDelegateProcessorMock(), - $this->createQueueMetaRegistry([]), - $this->createDriverMock() - ); - - $arguments = $command->getDefinition()->getArguments(); - - $this->assertCount(1, $arguments); - $this->assertArrayHasKey('client-queue-names', $arguments); - } - - public function testShouldExecuteConsumptionAndUseDefaultQueueName() - { - $queue = new NullQueue(''); - - $processor = $this->createDelegateProcessorMock(); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('close') - ; - - $consumer = $this->createQueueConsumerMock(); - $consumer - ->expects($this->once()) - ->method('bind') - ->with($this->identicalTo($queue), $this->identicalTo($processor)) - ; - $consumer - ->expects($this->once()) - ->method('consume') - ->with($this->isInstanceOf(ChainExtension::class)) - ; - $consumer - ->expects($this->once()) - ->method('getPsrContext') - ->will($this->returnValue($context)) - ; - - $queueMetaRegistry = $this->createQueueMetaRegistry([ - 'default' => [], - ]); - - $driver = $this->createDriverMock(); - $driver - ->expects($this->once()) - ->method('createQueue') - ->with('default') - ->willReturn($queue) - ; - - $command = new ConsumeMessagesCommand($consumer, $processor, $queueMetaRegistry, $driver); - - $tester = new CommandTester($command); - $tester->execute([]); - } - - public function testShouldExecuteConsumptionAndUseCustomClientDestinationName() - { - $queue = new NullQueue(''); - - $processor = $this->createDelegateProcessorMock(); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('close') - ; - - $consumer = $this->createQueueConsumerMock(); - $consumer - ->expects($this->once()) - ->method('bind') - ->with($this->identicalTo($queue), $this->identicalTo($processor)) - ; - $consumer - ->expects($this->once()) - ->method('consume') - ->with($this->isInstanceOf(ChainExtension::class)) - ; - $consumer - ->expects($this->once()) - ->method('getPsrContext') - ->will($this->returnValue($context)) - ; - - $queueMetaRegistry = $this->createQueueMetaRegistry([ - 'non-default-queue' => [], - ]); - - $driver = $this->createDriverMock(); - $driver - ->expects($this->once()) - ->method('createQueue') - ->with('non-default-queue') - ->willReturn($queue) - ; - - $command = new ConsumeMessagesCommand($consumer, $processor, $queueMetaRegistry, $driver); - - $tester = new CommandTester($command); - $tester->execute([ - 'client-queue-names' => ['non-default-queue'], - ]); - } - - /** - * @param array $destinationNames - * - * @return QueueMetaRegistry - */ - private function createQueueMetaRegistry(array $destinationNames) - { - $config = new Config( - 'aPrefix', - 'anApp', - 'aRouterTopicName', - 'aRouterQueueName', - 'aDefaultQueueName', - 'aRouterProcessorName' - ); - - return new QueueMetaRegistry($config, $destinationNames, 'default'); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context - */ - private function createPsrContextMock() - { - return $this->createMock(Context::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|DelegateProcessor - */ - private function createDelegateProcessorMock() - { - return $this->createMock(DelegateProcessor::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueConsumer - */ - private function createQueueConsumerMock() - { - return $this->createMock(QueueConsumer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface - */ - private function createDriverMock() - { - return $this->createMock(DriverInterface::class); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Client/ContainerAwareProcessorRegistryTest.php b/pkg/enqueue/Tests/Symfony/Client/ContainerAwareProcessorRegistryTest.php deleted file mode 100644 index aa2f744b4..000000000 --- a/pkg/enqueue/Tests/Symfony/Client/ContainerAwareProcessorRegistryTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertClassImplements(ProcessorRegistryInterface::class, ContainerAwareProcessorRegistry::class); - } - - public function testCouldBeConstructedWithoutAnyArgument() - { - new ContainerAwareProcessorRegistry(); - } - - public function testShouldThrowExceptionIfProcessorIsNotSet() - { - $this->setExpectedException( - \LogicException::class, - 'Processor was not found. processorName: "processor-name"' - ); - - $registry = new ContainerAwareProcessorRegistry(); - $registry->get('processor-name'); - } - - public function testShouldThrowExceptionIfContainerIsNotSet() - { - $this->setExpectedException(\LogicException::class, 'Container was not set'); - - $registry = new ContainerAwareProcessorRegistry(); - $registry->set('processor-name', 'processor-id'); - - $registry->get('processor-name'); - } - - public function testShouldThrowExceptionIfInstanceOfProcessorIsInvalid() - { - $this->setExpectedException(\LogicException::class, 'Container was not set'); - - $processor = new \stdClass(); - - $container = new Container(); - $container->set('processor-id', $processor); - - $registry = new ContainerAwareProcessorRegistry(); - $registry->set('processor-name', 'processor-id'); - - $registry->get('processor-name'); - } - - public function testShouldReturnInstanceOfProcessor() - { - $this->setExpectedException(\LogicException::class, 'Container was not set'); - - $processor = $this->createProcessorMock(); - - $container = new Container(); - $container->set('processor-id', $processor); - - $registry = new ContainerAwareProcessorRegistry(); - $registry->set('processor-name', 'processor-id'); - - $this->assertSame($processor, $registry->get('processor-name')); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor - */ - protected function createProcessorMock() - { - return $this->createMock(Processor::class); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPassTest.php new file mode 100644 index 000000000..568de6488 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/AnalyzeRouteCollectionPassTest.php @@ -0,0 +1,167 @@ +assertClassImplements(CompilerPassInterface::class, AnalyzeRouteCollectionPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(AnalyzeRouteCollectionPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new AnalyzeRouteCollectionPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoRouteCollectionServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + + $pass = new AnalyzeRouteCollectionPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.route_collection" not found'); + $pass->process($container); + } + + public function testThrowIfExclusiveCommandProcessorOnDefaultQueue() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aCommand', + Route::COMMAND, + 'aBarProcessor', + ['exclusive' => true] + ))->toArray(), + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The command "aCommand" processor "aBarProcessor" is exclusive but queue is not specified. Exclusive processors could not be run on a default queue.'); + $pass = new AnalyzeRouteCollectionPass(); + + $pass->process($container); + } + + public function testThrowIfTwoExclusiveCommandProcessorsWorkOnSamePrefixedQueue() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aFooCommand', + Route::COMMAND, + 'aFooProcessor', + ['exclusive' => true, 'queue' => 'aQueue', 'prefix_queue' => true] + ))->toArray(), + + (new Route( + 'aBarCommand', + Route::COMMAND, + 'aBarProcessor', + ['exclusive' => true, 'queue' => 'aQueue', 'prefix_queue' => true] + ))->toArray(), + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The command "aBarCommand" processor "aBarProcessor" is exclusive. The queue "aQueue" already has another exclusive command processor "aFooProcessor" bound to it.'); + $pass = new AnalyzeRouteCollectionPass(); + + $pass->process($container); + } + + public function testThrowIfTwoExclusiveCommandProcessorsWorkOnSameQueue() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aFooCommand', + Route::COMMAND, + 'aFooProcessor', + ['exclusive' => true, 'queue' => 'aQueue', 'prefix_queue' => false] + ))->toArray(), + + (new Route( + 'aBarCommand', + Route::COMMAND, + 'aBarProcessor', + ['exclusive' => true, 'queue' => 'aQueue', 'prefix_queue' => false] + ))->toArray(), + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The command "aBarCommand" processor "aBarProcessor" is exclusive. The queue "aQueue" already has another exclusive command processor "aFooProcessor" bound to it.'); + $pass = new AnalyzeRouteCollectionPass(); + + $pass->process($container); + } + + public function testThrowIfThereAreTwoQueuesWithSameNameAndOneNotPrefixed() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aFooCommand', + Route::COMMAND, + 'aFooProcessor', + ['queue' => 'foo', 'prefix_queue' => false] + ))->toArray(), + + (new Route( + 'aBarCommand', + Route::COMMAND, + 'aBarProcessor', + ['queue' => 'foo', 'prefix_queue' => true] + ))->toArray(), + ]); + + $pass = new AnalyzeRouteCollectionPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('There are prefixed and not prefixed queue with the same name "foo". This is not allowed.'); + $pass->process($container); + } + + public function testThrowIfDefaultQueueNotPrefixed() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aFooCommand', + Route::COMMAND, + 'aFooProcessor', + ['queue' => null, 'prefix_queue' => false] + ))->toArray(), + ]); + + $pass = new AnalyzeRouteCollectionPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The default queue must be prefixed.'); + $pass->process($container); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildClientExtensionsPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildClientExtensionsPassTest.php new file mode 100644 index 000000000..753790369 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildClientExtensionsPassTest.php @@ -0,0 +1,283 @@ +assertClassImplements(CompilerPassInterface::class, BuildClientExtensionsPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildClientExtensionsPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildClientExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoClientExtensionsServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'foo'); + + $pass = new BuildClientExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.client_extensions" not found'); + $pass->process($container); + } + + public function testShouldRegisterClientExtension() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.aName.client_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'aName']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'aName']) + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldIgnoreOtherClientExtensions() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.aName.client_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'aName']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'anotherName']) + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldAddExtensionIfClientAll() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.aName.client_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'all']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'anotherName']) + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldTreatTagsWithoutClientAsDefaultClient() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.client_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension') + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldOrderExtensionsByPriority() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.client.foo.client_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension', ['priority' => 6]); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension', ['priority' => -5]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension', ['priority' => 2]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[2]); + } + + public function testShouldAssumePriorityZeroIfPriorityIsNotSet() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.client.foo.client_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension'); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension', ['priority' => 1]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.client_extension', ['priority' => -1]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[2]); + } + + public function testShouldMergeWithAddedPreviously() + { + $extensions = new Definition(); + $extensions->addArgument([ + 'aBarExtension' => 'aBarServiceIdAddedPreviously', + 'aOloloExtension' => 'aOloloServiceIdAddedPreviously', + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.client_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension') + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertCount(4, $extensions->getArgument(0)); + } + + public function testShouldRegisterProcessorWithMatchedNameToCorrespondingExtensions() + { + $fooExtensions = new Definition(); + $fooExtensions->addArgument([]); + + $barExtensions = new Definition(); + $barExtensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.client_extensions', $fooExtensions); + $container->setDefinition('enqueue.client.bar.client_extensions', $barExtensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.client_extension', ['client' => 'bar']) + ; + + $pass = new BuildClientExtensionsPass(); + $pass->process($container); + + self::assertIsArray($fooExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $fooExtensions->getArgument(0)); + + self::assertIsArray($barExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aBarExtension'), + ], $barExtensions->getArgument(0)); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPassTest.php new file mode 100644 index 000000000..e1ed297c6 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildCommandSubscriberRoutesPassTest.php @@ -0,0 +1,459 @@ +assertClassImplements(CompilerPassInterface::class, BuildCommandSubscriberRoutesPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildCommandSubscriberRoutesPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildCommandSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoRouteCollectionServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'baz'); + + $pass = new BuildCommandSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.route_collection" not found'); + $pass->process($container); + } + + public function testThrowIfTaggedProcessorIsBuiltByFactory() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->register('enqueue.client.aName.route_collection', RouteCollection::class) + ->addArgument([]) + ; + $container->register('aProcessor', Processor::class) + ->setFactory('foo') + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The command subscriber tag could not be applied to a service created by factory.'); + $pass->process($container); + } + + public function testShouldRegisterProcessorWithMatchedName() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber', ['client' => 'foo']) + ; + $container->register('aProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorWithoutNameToDefaultClient() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber') + ; + $container->register('aProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorIfClientNameEqualsAll() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber', ['client' => 'all']) + ; + $container->register('aProcessor', get_class($this->createCommandSubscriberProcessor())) + ->addTag('enqueue.command_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorIfCommandsIsString() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor('fooCommand'); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testThrowIfCommandSubscriberReturnsNothing() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor(null); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Command subscriber must return something.'); + $pass->process($container); + } + + public function testShouldRegisterProcessorIfCommandsAreStrings() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor(['fooCommand', 'barCommand']); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(2, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + [ + 'source' => 'barCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegisterProcessorIfParamSingleCommandArray() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor([ + 'command' => 'fooCommand', + 'processor' => 'aCustomFooProcessorName', + 'anOption' => 'aFooVal', + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aCustomFooProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aFooVal', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegisterProcessorIfCommandsAreParamArrays() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor([ + ['command' => 'fooCommand', 'processor' => 'aCustomFooProcessorName', 'anOption' => 'aFooVal'], + ['command' => 'barCommand', 'processor' => 'aCustomBarProcessorName', 'anOption' => 'aBarVal'], + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(2, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aCustomFooProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aFooVal', + ], + [ + 'source' => 'barCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aCustomBarProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aBarVal', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testThrowIfCommandSubscriberParamsInvalid() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor(['fooBar', true]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Command subscriber configuration is invalid'); + $pass->process($container); + } + + public function testShouldMergeExtractedRoutesWithAlreadySetInCollection() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([ + (new Route('aCommand', Route::COMMAND, 'aProcessor'))->toArray(), + (new Route('aCommand', Route::COMMAND, 'aProcessor'))->toArray(), + ]); + + $processor = $this->createCommandSubscriberProcessor(['fooCommand']); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(3, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegister08CommandProcessor() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createCommandSubscriberProcessor([ + 'processorName' => 'fooCommand', + 'queueName' => 'a_client_queue_name', + 'queueNameHardcoded' => true, + 'exclusive' => true, + 'anOption' => 'aFooVal', + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['default']); + $container->setParameter('enqueue.default_client', 'default'); + $container->setDefinition('enqueue.client.default.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.command_subscriber') + ; + + $pass = new BuildCommandSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aFooVal', + 'queue' => 'a_client_queue_name', + 'prefix_queue' => false, + ], + ], + $routeCollection->getArgument(0) + ); + } + + private function createCommandSubscriberProcessor($commandSubscriberReturns = ['aCommand']) + { + $processor = new class implements Processor, CommandSubscriberInterface { + public static $return; + + public function process(InteropMessage $message, Context $context) + { + return self::ACK; + } + + public static function getSubscribedCommand() + { + return static::$return; + } + }; + + $processor::$return = $commandSubscriberReturns; + + return $processor; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPassTest.php new file mode 100644 index 000000000..c2975051b --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildConsumptionExtensionsPassTest.php @@ -0,0 +1,283 @@ +assertClassImplements(CompilerPassInterface::class, BuildConsumptionExtensionsPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildConsumptionExtensionsPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildConsumptionExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoConsumptionExtensionsServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'baz'); + + $pass = new BuildConsumptionExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.consumption_extensions" not found'); + $pass->process($container); + } + + public function testShouldRegisterClientExtension() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'foo']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldIgnoreOtherClientExtensions() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'anotherName']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldAddExtensionIfClientAll() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'all']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'anotherName']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldTreatTagsWithoutClientAsDefaultClient() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension') + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldOrderExtensionsByPriority() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension', ['priority' => 6]); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension', ['priority' => -5]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension', ['priority' => 2]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[2]); + } + + public function testShouldAssumePriorityZeroIfPriorityIsNotSet() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension'); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension', ['priority' => 1]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.consumption_extension', ['priority' => -1]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[2]); + } + + public function testShouldMergeWithAddedPreviously() + { + $extensions = new Definition(); + $extensions->addArgument([ + 'aBarExtension' => 'aBarServiceIdAddedPreviously', + 'aOloloExtension' => 'aOloloServiceIdAddedPreviously', + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension') + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertCount(4, $extensions->getArgument(0)); + } + + public function testShouldRegisterProcessorWithMatchedNameToCorrespondingExtensions() + { + $fooExtensions = new Definition(); + $fooExtensions->addArgument([]); + + $barExtensions = new Definition(); + $barExtensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.consumption_extensions', $fooExtensions); + $container->setDefinition('enqueue.client.bar.consumption_extensions', $barExtensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.consumption_extension', ['client' => 'bar']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($fooExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $fooExtensions->getArgument(0)); + + self::assertIsArray($barExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aBarExtension'), + ], $barExtensions->getArgument(0)); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRegistryPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRegistryPassTest.php new file mode 100644 index 000000000..5c9ac4840 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRegistryPassTest.php @@ -0,0 +1,151 @@ +assertClassImplements(CompilerPassInterface::class, BuildProcessorRegistryPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildProcessorRegistryPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoProcessorRegistryServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.processor_registry" not found'); + $pass->process($container); + } + + public function testThrowsIfNoRouteCollectionServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->register('enqueue.client.foo.processor_registry'); + + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.route_collection" not found'); + $pass->process($container); + } + + public function testThrowsIfNoRouteProcessorServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->register('enqueue.client.foo.processor_registry'); + $container->register('enqueue.client.foo.route_collection'); + + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.router_processor" not found'); + $pass->process($container); + } + + public function testThrowIfProcessorServiceIdOptionNotSet() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route('aCommand', Route::COMMAND, 'aProcessor'))->toArray(), + ]); + $container->register('enqueue.client.aName.processor_registry')->addArgument([]); + $container->register('enqueue.client.aName.router_processor'); + + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The route option "processor_service_id" is required'); + $pass->process($container); + } + + public function testShouldPassLocatorAsFirstArgument() + { + $registry = new Definition(); + $registry->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['aName']); + $container->register('enqueue.client.aName.route_collection')->addArgument([ + (new Route( + 'aCommand', + Route::COMMAND, + 'aBarProcessor', + ['processor_service_id' => 'aBarServiceId'] + ))->toArray(), + (new Route( + 'aTopic', + Route::TOPIC, + 'aFooProcessor', + ['processor_service_id' => 'aFooServiceId'] + ))->toArray(), + ]); + $container->setDefinition('enqueue.client.aName.processor_registry', $registry); + $container->register('enqueue.client.aName.router_processor'); + + $pass = new BuildProcessorRegistryPass(); + $pass->process($container); + + $this->assertLocatorServices($container, $registry->getArgument(0), [ + '%enqueue.client.aName.router_processor%' => 'enqueue.client.aName.router_processor', + 'aBarProcessor' => 'aBarServiceId', + 'aFooProcessor' => 'aFooServiceId', + ]); + } + + private function assertLocatorServices(ContainerBuilder $container, $locatorId, array $locatorServices) + { + $this->assertInstanceOf(Reference::class, $locatorId); + $locatorId = (string) $locatorId; + + $this->assertTrue($container->hasDefinition($locatorId)); + $this->assertMatchesRegularExpression('/\.?service_locator\..*?\.enqueue\./', $locatorId); + + $match = []; + if (false == preg_match('/(\.?service_locator\..*?)\.enqueue\./', $locatorId, $match)) { + $this->fail('preg_match should not failed'); + } + + $this->assertTrue($container->hasDefinition($match[1])); + $locator = $container->getDefinition($match[1]); + + $this->assertContainsOnly(ServiceClosureArgument::class, $locator->getArgument(0)); + $actualServices = array_map(function (ServiceClosureArgument $value) { + return (string) $value->getValues()[0]; + }, $locator->getArgument(0)); + + $this->assertEquals($locatorServices, $actualServices); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRoutesPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRoutesPassTest.php new file mode 100644 index 000000000..0351c45f5 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildProcessorRoutesPassTest.php @@ -0,0 +1,302 @@ +assertClassImplements(CompilerPassInterface::class, BuildProcessorRoutesPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildProcessorRoutesPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildProcessorRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoRouteCollectionServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'baz'); + + $pass = new BuildProcessorRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.route_collection" not found'); + $pass->process($container); + } + + public function testThrowIfBothTopicAndCommandAttributesAreSet() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['topic' => 'foo', 'command' => 'bar']) + ; + + $pass = new BuildProcessorRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either "topic" or "command" tag attribute must be set on service "aFooProcessor". Both are set.'); + $pass->process($container); + } + + public function testThrowIfNeitherTopicNorCommandAttributesAreSet() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', []) + ; + + $pass = new BuildProcessorRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either "topic" or "command" tag attribute must be set on service "aFooProcessor". None is set.'); + $pass->process($container); + } + + public function testShouldRegisterProcessorWithMatchedName() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'bar'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['client' => 'foo', 'topic' => 'foo']) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['client' => 'bar', 'command' => 'foo']) + ; + + $pass = new BuildProcessorRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorWithoutNameToDefaultClient() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['topic' => 'foo']) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['client' => 'bar', 'command' => 'foo']) + ; + + $pass = new BuildProcessorRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorIfClientNameEqualsAll() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['client' => 'all', 'topic' => 'foo']) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['client' => 'bar', 'command' => 'foo']) + ; + + $pass = new BuildProcessorRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterAsTopicProcessor() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['topic' => 'aTopic']) + ; + + $pass = new BuildProcessorRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegisterAsCommandProcessor() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['command' => 'aCommand']) + ; + + $pass = new BuildProcessorRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegisterWithCustomProcessorName() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['command' => 'aCommand', 'processor' => 'customProcessorName']) + ; + + $pass = new BuildProcessorRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'customProcessorName', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldMergeExtractedRoutesWithAlreadySetInCollection() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([ + (new Route('aTopic', Route::TOPIC, 'aProcessor'))->toArray(), + (new Route('aCommand', Route::COMMAND, 'aProcessor'))->toArray(), + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.processor', ['command' => 'fooCommand']) + ; + + $pass = new BuildProcessorRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(3, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'fooCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPassTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPassTest.php new file mode 100644 index 000000000..a954d9a41 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/BuildTopicSubscriberRoutesPassTest.php @@ -0,0 +1,423 @@ +assertClassImplements(CompilerPassInterface::class, BuildTopicSubscriberRoutesPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildTopicSubscriberRoutesPass::class); + } + + public function testThrowIfEnqueueClientsParameterNotSet() + { + $pass = new BuildTopicSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.clients" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoRouteCollectionServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo', 'bar']); + $container->setParameter('enqueue.default_client', 'baz'); + + $pass = new BuildTopicSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.client.foo.route_collection" not found'); + $pass->process($container); + } + + public function testThrowIfTaggedProcessorIsBuiltByFactory() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->register('enqueue.client.foo.route_collection', RouteCollection::class) + ->addArgument([]) + ; + $container->register('aProcessor', Processor::class) + ->setFactory('foo') + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The topic subscriber tag could not be applied to a service created by factory.'); + $pass->process($container); + } + + public function testShouldRegisterProcessorWithMatchedName() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'bar'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber', ['client' => 'foo']) + ; + $container->register('aProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorWithoutNameToDefaultClient() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber') + ; + $container->register('aProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorIfClientNameEqualsAll() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber', ['client' => 'all']) + ; + $container->register('aProcessor', get_class($this->createTopicSubscriberProcessor())) + ->addTag('enqueue.topic_subscriber', ['client' => 'bar']) + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + } + + public function testShouldRegisterProcessorIfTopicsIsString() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor('fooTopic'); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(1, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testThrowIfTopicSubscriberReturnsNothing() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor(null); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Topic subscriber must return something.'); + $pass->process($container); + } + + public function testShouldRegisterProcessorIfTopicsAreStrings() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor(['fooTopic', 'barTopic']); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(2, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + [ + 'source' => 'barTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegisterProcessorIfTopicsAreParamArrays() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor([ + ['topic' => 'fooTopic', 'processor' => 'aCustomFooProcessorName', 'anOption' => 'aFooVal'], + ['topic' => 'barTopic', 'processor' => 'aCustomBarProcessorName', 'anOption' => 'aBarVal'], + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(2, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aCustomFooProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aFooVal', + ], + [ + 'source' => 'barTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aCustomBarProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aBarVal', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testThrowIfTopicSubscriberParamsInvalid() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor(['fooBar', true]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Topic subscriber configuration is invalid'); + $pass->process($container); + } + + public function testShouldMergeExtractedRoutesWithAlreadySetInCollection() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([ + (new Route('aTopic', Route::TOPIC, 'aProcessor'))->toArray(), + (new Route('aCommand', Route::COMMAND, 'aProcessor'))->toArray(), + ]); + + $processor = $this->createTopicSubscriberProcessor(['fooTopic']); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['foo']); + $container->setParameter('enqueue.default_client', 'foo'); + $container->setDefinition('enqueue.client.foo.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(3, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'aTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'aCommand', + 'source_type' => 'enqueue.client.command_route', + 'processor' => 'aProcessor', + ], + [ + 'source' => 'fooTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aFooProcessor', + 'processor_service_id' => 'aFooProcessor', + ], + ], + $routeCollection->getArgument(0) + ); + } + + public function testShouldRegister08TopicSubscriber() + { + $routeCollection = new Definition(RouteCollection::class); + $routeCollection->addArgument([]); + + $processor = $this->createTopicSubscriberProcessor([ + 'fooTopic' => ['processorName' => 'aCustomFooProcessorName', 'queueName' => 'fooQueue', 'queueNameHardcoded' => true, 'anOption' => 'aFooVal'], + 'barTopic' => ['processorName' => 'aCustomBarProcessorName', 'anOption' => 'aBarVal'], + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.clients', ['default']); + $container->setParameter('enqueue.default_client', 'default'); + $container->setDefinition('enqueue.client.default.route_collection', $routeCollection); + $container->register('aFooProcessor', $processor::class) + ->addTag('enqueue.topic_subscriber') + ; + + $pass = new BuildTopicSubscriberRoutesPass(); + $pass->process($container); + + self::assertIsArray($routeCollection->getArgument(0)); + $this->assertCount(2, $routeCollection->getArgument(0)); + + $this->assertEquals( + [ + [ + 'source' => 'fooTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aCustomFooProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aFooVal', + 'queue' => 'fooQueue', + 'prefix_queue' => false, + ], + [ + 'source' => 'barTopic', + 'source_type' => 'enqueue.client.topic_route', + 'processor' => 'aCustomBarProcessorName', + 'processor_service_id' => 'aFooProcessor', + 'anOption' => 'aBarVal', + ], + ], + $routeCollection->getArgument(0) + ); + } + + private function createTopicSubscriberProcessor($topicSubscriberReturns = ['aTopic']) + { + $processor = new class implements Processor, TopicSubscriberInterface { + public static $return; + + public function process(InteropMessage $message, Context $context) + { + return self::ACK; + } + + public static function getSubscribedTopics() + { + return static::$return; + } + }; + + $processor::$return = $topicSubscriberReturns; + + return $processor; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/ClientFactoryTest.php b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/ClientFactoryTest.php new file mode 100644 index 000000000..9f37dff47 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/DependencyInjection/ClientFactoryTest.php @@ -0,0 +1,54 @@ +assertClassFinal(ClientFactory::class); + } + + public function testThrowIfEmptyNameGivenOnConstruction() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The name could not be empty.'); + + new ClientFactory(''); + } + + public function testShouldCreateDriverFromDsn() + { + $container = new ContainerBuilder(); + + $transport = new ClientFactory('default'); + + $serviceId = $transport->createDriver($container, ['dsn' => 'foo://bar/baz', 'foo' => 'fooVal']); + + $this->assertEquals('enqueue.client.default.driver', $serviceId); + + $this->assertTrue($container->hasDefinition('enqueue.client.default.driver')); + + $this->assertNotEmpty($container->getDefinition('enqueue.client.default.driver')->getFactory()); + $this->assertEquals( + [new Reference('enqueue.client.default.driver_factory'), 'create'], + $container->getDefinition('enqueue.client.default.driver')->getFactory()) + ; + $this->assertEquals( + [ + new Reference('enqueue.transport.default.connection_factory'), + new Reference('enqueue.client.default.config'), + new Reference('enqueue.client.default.route_collection'), + ], + $container->getDefinition('enqueue.client.default.driver')->getArguments()) + ; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/FlushSpoolProducerListenerTest.php b/pkg/enqueue/Tests/Symfony/Client/FlushSpoolProducerListenerTest.php new file mode 100644 index 000000000..539d332ee --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/FlushSpoolProducerListenerTest.php @@ -0,0 +1,62 @@ +assertClassImplements(EventSubscriberInterface::class, FlushSpoolProducerListener::class); + } + + public function testShouldSubscribeOnKernelTerminateEvent() + { + $events = FlushSpoolProducerListener::getSubscribedEvents(); + + self::assertIsArray($events); + $this->assertArrayHasKey(KernelEvents::TERMINATE, $events); + + $this->assertEquals('flushMessages', $events[KernelEvents::TERMINATE]); + } + + public function testShouldSubscribeOnConsoleTerminateEvent() + { + $events = FlushSpoolProducerListener::getSubscribedEvents(); + + self::assertIsArray($events); + $this->assertArrayHasKey(ConsoleEvents::TERMINATE, $events); + + $this->assertEquals('flushMessages', $events[ConsoleEvents::TERMINATE]); + } + + public function testShouldFlushSpoolProducerOnFlushMessagesCall() + { + $producerMock = $this->createSpoolProducerMock(); + $producerMock + ->expects($this->once()) + ->method('flush') + ; + + $listener = new FlushSpoolProducerListener($producerMock); + + $listener->flushMessages(); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SpoolProducer + */ + private function createSpoolProducerMock() + { + return $this->createMock(SpoolProducer::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/Meta/QueuesCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/Meta/QueuesCommandTest.php deleted file mode 100644 index 048e7d3fe..000000000 --- a/pkg/enqueue/Tests/Symfony/Client/Meta/QueuesCommandTest.php +++ /dev/null @@ -1,107 +0,0 @@ -assertClassExtends(Command::class, QueuesCommand::class); - } - - public function testCouldBeConstructedWithQueueMetaRegistryAsFirstArgument() - { - new QueuesCommand($this->createQueueMetaRegistryStub()); - } - - public function testShouldHaveCommandName() - { - $command = new QueuesCommand($this->createQueueMetaRegistryStub()); - - $this->assertEquals('enqueue:queues', $command->getName()); - } - - public function testShouldHaveCommandAliases() - { - $command = new QueuesCommand($this->createQueueMetaRegistryStub()); - - $this->assertEquals(['enq:m:q', 'debug:enqueue:queues'], $command->getAliases()); - } - - public function testShouldShowMessageFoundZeroDestinationsIfAnythingInRegistry() - { - $command = new QueuesCommand($this->createQueueMetaRegistryStub()); - - $output = $this->executeCommand($command); - - $this->assertContains('Found 0 destinations', $output); - } - - public function testShouldShowMessageFoundTwoDestinations() - { - $command = new QueuesCommand($this->createQueueMetaRegistryStub([ - new QueueMeta('aClientName', 'aDestinationName'), - new QueueMeta('anotherClientName', 'anotherDestinationName'), - ])); - - $output = $this->executeCommand($command); - - $this->assertContains('Found 2 destinations', $output); - } - - public function testShouldShowInfoAboutDestinations() - { - $command = new QueuesCommand($this->createQueueMetaRegistryStub([ - new QueueMeta('aFooClientName', 'aFooDestinationName', ['fooSubscriber']), - new QueueMeta('aBarClientName', 'aBarDestinationName', ['barSubscriber']), - ])); - - $output = $this->executeCommand($command); - - $this->assertContains('aFooClientName', $output); - $this->assertContains('aFooDestinationName', $output); - $this->assertContains('fooSubscriber', $output); - $this->assertContains('aBarClientName', $output); - $this->assertContains('aBarDestinationName', $output); - $this->assertContains('barSubscriber', $output); - } - - /** - * @param Command $command - * @param string[] $arguments - * - * @return string - */ - protected function executeCommand(Command $command, array $arguments = []) - { - $tester = new CommandTester($command); - $tester->execute($arguments); - - return $tester->getDisplay(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueMetaRegistry - * @param mixed $destinations - */ - protected function createQueueMetaRegistryStub($destinations = []) - { - $registryMock = $this->createMock(QueueMetaRegistry::class); - $registryMock - ->expects($this->any()) - ->method('getQueuesMeta') - ->willReturn($destinations) - ; - - return $registryMock; - } -} diff --git a/pkg/enqueue/Tests/Symfony/Client/Meta/TopicsCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/Meta/TopicsCommandTest.php deleted file mode 100644 index 3cf4c5efb..000000000 --- a/pkg/enqueue/Tests/Symfony/Client/Meta/TopicsCommandTest.php +++ /dev/null @@ -1,90 +0,0 @@ -assertClassExtends(Command::class, TopicsCommand::class); - } - - public function testCouldBeConstructedWithTopicMetaRegistryAsFirstArgument() - { - new TopicsCommand(new TopicMetaRegistry([])); - } - - public function testShouldHaveCommandName() - { - $command = new TopicsCommand(new TopicMetaRegistry([])); - - $this->assertEquals('enqueue:topics', $command->getName()); - } - - public function testShouldHaveCommandAliases() - { - $command = new TopicsCommand(new TopicMetaRegistry([])); - - $this->assertEquals(['enq:m:t', 'debug:enqueue:topics'], $command->getAliases()); - } - - public function testShouldShowMessageFoundZeroTopicsIfAnythingInRegistry() - { - $command = new TopicsCommand(new TopicMetaRegistry([])); - - $output = $this->executeCommand($command); - - $this->assertContains('Found 0 topics', $output); - } - - public function testShouldShowMessageFoundTwoTopics() - { - $command = new TopicsCommand(new TopicMetaRegistry([ - 'fooTopic' => [], - 'barTopic' => [], - ])); - - $output = $this->executeCommand($command); - - $this->assertContains('Found 2 topics', $output); - } - - public function testShouldShowInfoAboutTopics() - { - $command = new TopicsCommand(new TopicMetaRegistry([ - 'fooTopic' => ['description' => 'fooDescription', 'processors' => ['fooSubscriber']], - 'barTopic' => ['description' => 'barDescription', 'processors' => ['barSubscriber']], - ])); - - $output = $this->executeCommand($command); - - $this->assertContains('fooTopic', $output); - $this->assertContains('fooDescription', $output); - $this->assertContains('fooSubscriber', $output); - $this->assertContains('barTopic', $output); - $this->assertContains('barDescription', $output); - $this->assertContains('barSubscriber', $output); - } - - /** - * @param Command $command - * @param string[] $arguments - * - * @return string - */ - protected function executeCommand(Command $command, array $arguments = []) - { - $tester = new CommandTester($command); - $tester->execute($arguments); - - return $tester->getDisplay(); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Client/Mock/SetupBrokerExtensionCommand.php b/pkg/enqueue/Tests/Symfony/Client/Mock/SetupBrokerExtensionCommand.php index 603fb090a..c21750592 100644 --- a/pkg/enqueue/Tests/Symfony/Client/Mock/SetupBrokerExtensionCommand.php +++ b/pkg/enqueue/Tests/Symfony/Client/Mock/SetupBrokerExtensionCommand.php @@ -3,9 +3,10 @@ namespace Enqueue\Tests\Symfony\Client\Mock; use Enqueue\Client\Config; -use Enqueue\Client\NullDriver; +use Enqueue\Client\Driver\GenericDriver; +use Enqueue\Client\RouteCollection; +use Enqueue\Null\NullContext; use Enqueue\Symfony\Client\SetupBrokerExtensionCommandTrait; -use Enqueue\Transport\Null\NullContext; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -28,8 +29,14 @@ protected function configure() $this->configureSetupBrokerExtension(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->extension = $this->getSetupBrokerExtension($input, new NullDriver(new NullContext(), Config::create())); + $this->extension = $this->getSetupBrokerExtension($input, new GenericDriver( + new NullContext(), + Config::create(), + new RouteCollection([]) + )); + + return 0; } } diff --git a/pkg/enqueue/Tests/Symfony/Client/ProduceCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/ProduceCommandTest.php new file mode 100644 index 000000000..daa909175 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/ProduceCommandTest.php @@ -0,0 +1,284 @@ +assertClassExtends(Command::class, ProduceCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(ProduceCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = ProduceCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); + + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:produce', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); + } + + public function testShouldHaveExpectedOptions() + { + $command = new ProduceCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + $this->assertCount(4, $options); + $this->assertArrayHasKey('client', $options); + $this->assertArrayHasKey('topic', $options); + $this->assertArrayHasKey('command', $options); + $this->assertArrayHasKey('header', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new ProduceCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + $this->assertCount(1, $arguments); + + $this->assertArrayHasKey('message', $arguments); + } + + public function testThrowIfNeitherTopicNorCommandOptionsAreSet() + { + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $producerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $producerMock, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either topic or command option should be set, none is set.'); + $tester->execute([ + 'message' => 'theMessage', + ]); + } + + public function testThrowIfBothTopicAndCommandOptionsAreSet() + { + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $producerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $producerMock, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either topic or command option should be set, both are set.'); + $tester->execute([ + 'message' => 'theMessage', + '--topic' => 'theTopic', + '--command' => 'theCommand', + ]); + } + + public function testShouldSendEventToDefaultTransport() + { + $header = 'Content-Type: text/plain'; + $payload = 'theMessage'; + + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->once()) + ->method('sendEvent') + ->with('theTopic', new Message($payload, [], [$header])) + ; + $producerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $producerMock, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'message' => $payload, + '--header' => $header, + '--topic' => 'theTopic', + ]); + } + + public function testShouldSendCommandToDefaultTransport() + { + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->once()) + ->method('sendCommand') + ->with('theCommand', 'theMessage') + ; + $producerMock + ->expects($this->never()) + ->method('sendEvent') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $producerMock, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'message' => 'theMessage', + '--command' => 'theCommand', + ]); + } + + public function testShouldSendEventToFooTransport() + { + $header = 'Content-Type: text/plain'; + $payload = 'theMessage'; + + $defaultProducerMock = $this->createProducerMock(); + $defaultProducerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $defaultProducerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $fooProducerMock = $this->createProducerMock(); + $fooProducerMock + ->expects($this->once()) + ->method('sendEvent') + ->with('theTopic', new Message($payload, [], [$header])) + ; + $fooProducerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $defaultProducerMock, + 'enqueue.client.foo.producer' => $fooProducerMock, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'message' => $payload, + '--header' => $header, + '--topic' => 'theTopic', + '--client' => 'foo', + ]); + } + + public function testShouldSendCommandToFooTransport() + { + $defaultProducerMock = $this->createProducerMock(); + $defaultProducerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $defaultProducerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $fooProducerMock = $this->createProducerMock(); + $fooProducerMock + ->expects($this->once()) + ->method('sendCommand') + ->with('theCommand', 'theMessage') + ; + $fooProducerMock + ->expects($this->never()) + ->method('sendEvent') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $defaultProducerMock, + 'enqueue.client.foo.producer' => $fooProducerMock, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'message' => 'theMessage', + '--command' => 'theCommand', + '--client' => 'foo', + ]); + } + + public function testThrowIfClientNotFound() + { + $defaultProducerMock = $this->createProducerMock(); + $defaultProducerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $defaultProducerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new ProduceCommand(new Container([ + 'enqueue.client.default.producer' => $defaultProducerMock, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Client "bar" is not supported.'); + $tester->execute([ + 'message' => 'theMessage', + '--command' => 'theCommand', + '--client' => 'bar', + ]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerInterface + */ + private function createProducerMock() + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/ProduceMessageCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/ProduceMessageCommandTest.php deleted file mode 100644 index 6cbfbfc1d..000000000 --- a/pkg/enqueue/Tests/Symfony/Client/ProduceMessageCommandTest.php +++ /dev/null @@ -1,74 +0,0 @@ -createProducerMock()); - } - - public function testShouldHaveCommandName() - { - $command = new ProduceMessageCommand($this->createProducerMock()); - - $this->assertEquals('enqueue:produce', $command->getName()); - } - - public function testShouldHaveCommandAliases() - { - $command = new ProduceMessageCommand($this->createProducerMock()); - - $this->assertEquals(['enq:p'], $command->getAliases()); - } - - public function testShouldHaveExpectedOptions() - { - $command = new ProduceMessageCommand($this->createProducerMock()); - - $options = $command->getDefinition()->getOptions(); - $this->assertCount(0, $options); - } - - public function testShouldHaveExpectedAttributes() - { - $command = new ProduceMessageCommand($this->createProducerMock()); - - $arguments = $command->getDefinition()->getArguments(); - $this->assertCount(2, $arguments); - - $this->assertArrayHasKey('topic', $arguments); - $this->assertArrayHasKey('message', $arguments); - } - - public function testShouldExecuteConsumptionAndUseDefaultQueueName() - { - $producerMock = $this->createProducerMock(); - $producerMock - ->expects($this->once()) - ->method('send') - ->with('theTopic', 'theMessage') - ; - - $command = new ProduceMessageCommand($producerMock); - - $tester = new CommandTester($command); - $tester->execute([ - 'topic' => 'theTopic', - 'message' => 'theMessage', - ]); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducerInterface - */ - private function createProducerMock() - { - return $this->createMock(MessageProducerInterface::class); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Client/RoutesCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/RoutesCommandTest.php new file mode 100644 index 000000000..89bd7f745 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/RoutesCommandTest.php @@ -0,0 +1,366 @@ +assertClassExtends(Command::class, RoutesCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(RoutesCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = RoutesCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); + + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:routes', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); + } + + public function testShouldHaveCommandAliases() + { + $command = new RoutesCommand($this->createMock(ContainerInterface::class), 'default'); + + $this->assertEquals(['debug:enqueue:routes'], $command->getAliases()); + } + + public function testShouldHaveExpectedOptions() + { + $command = new RoutesCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + $this->assertCount(2, $options); + + $this->assertArrayHasKey('show-route-options', $options); + $this->assertArrayHasKey('client', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new RoutesCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + $this->assertCount(0, $arguments); + } + + public function testShouldOutputEmptyRouteCollection() + { + $routeCollection = new RouteCollection([]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<<'OUTPUT' +Found 0 routes + + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldUseFooDriver() + { + $routeCollection = new RouteCollection([ + new Route('fooTopic', Route::TOPIC, 'processor'), + ]); + + $defaultDriverMock = $this->createMock(DriverInterface::class); + $defaultDriverMock + ->expects($this->never()) + ->method('getConfig') + ; + + $defaultDriverMock + ->expects($this->never()) + ->method('getRouteCollection') + ; + + $fooDriverMock = $this->createDriverStub(Config::create(), $routeCollection); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $defaultDriverMock, + 'enqueue.client.foo.driver' => $fooDriverMock, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + '--client' => 'foo', + ]); + + $this->assertStringContainsString('Found 1 routes', $tester->getDisplay()); + } + + public function testThrowIfClientNotFound() + { + $defaultDriverMock = $this->createMock(DriverInterface::class); + $defaultDriverMock + ->expects($this->never()) + ->method('getConfig') + ; + + $defaultDriverMock + ->expects($this->never()) + ->method('getRouteCollection') + ; + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $defaultDriverMock, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Client "foo" is not supported.'); + $tester->execute([ + '--client' => 'foo', + ]); + } + + public function testShouldOutputTopicRouteInfo() + { + $routeCollection = new RouteCollection([ + new Route('fooTopic', Route::TOPIC, 'processor'), + new Route('barTopic', Route::TOPIC, 'processor'), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<<'OUTPUT' +Found 2 routes + ++-------+----------+--------------------+-----------+----------+ +| Type | Source | Queue | Processor | Options | ++-------+----------+--------------------+-----------+----------+ +| topic | fooTopic | default (prefixed) | processor | (hidden) | +| topic | barTopic | default (prefixed) | processor | (hidden) | ++-------+----------+--------------------+-----------+----------+ + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldOutputCommandRouteInfo() + { + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'processor', ['foo' => 'fooVal', 'bar' => 'barVal']), + new Route('barCommand', Route::COMMAND, 'processor', ['foo' => 'fooVal', 'bar' => 'barVal']), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<<'OUTPUT' +Found 2 routes + ++---------+------------+--------------------+-----------+----------+ +| Type | Source | Queue | Processor | Options | ++---------+------------+--------------------+-----------+----------+ +| command | fooCommand | default (prefixed) | processor | (hidden) | +| command | barCommand | default (prefixed) | processor | (hidden) | ++---------+------------+--------------------+-----------+----------+ + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldCorrectlyOutputPrefixedCustomQueue() + { + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'processor', ['queue' => 'foo']), + new Route('barTopic', Route::TOPIC, 'processor', ['queue' => 'bar']), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + $this->assertSame(0, $exitCode); + + $expectedOutput = <<<'OUTPUT' +Found 2 routes + ++---------+------------+----------------+-----------+----------+ +| Type | Source | Queue | Processor | Options | ++---------+------------+----------------+-----------+----------+ +| topic | barTopic | bar (prefixed) | processor | (hidden) | +| command | fooCommand | foo (prefixed) | processor | (hidden) | ++---------+------------+----------------+-----------+----------+ + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldCorrectlyOutputNotPrefixedCustomQueue() + { + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'processor', ['queue' => 'foo', 'prefix_queue' => false]), + new Route('barTopic', Route::TOPIC, 'processor', ['queue' => 'bar', 'prefix_queue' => false]), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<<'OUTPUT' +Found 2 routes + ++---------+------------+-------------+-----------+----------+ +| Type | Source | Queue | Processor | Options | ++---------+------------+-------------+-----------+----------+ +| topic | barTopic | bar (as is) | processor | (hidden) | +| command | fooCommand | foo (as is) | processor | (hidden) | ++---------+------------+-------------+-----------+----------+ + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldCorrectlyOutputExternalRoute() + { + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'processor', ['external' => true]), + new Route('barTopic', Route::TOPIC, 'processor', ['external' => true]), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<assertCommandOutput($expectedOutput, $tester); + } + + public function testShouldOutputRouteOptions() + { + $routeCollection = new RouteCollection([ + new Route('fooCommand', Route::COMMAND, 'processor', ['foo' => 'fooVal']), + new Route('barTopic', Route::TOPIC, 'processor', ['bar' => 'barVal']), + ]); + + $command = new RoutesCommand(new Container([ + 'enqueue.client.default.driver' => $this->createDriverStub(Config::create(), $routeCollection), + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute(['--show-route-options' => true]); + + $expectedOutput = <<<'OUTPUT' +Found 2 routes + ++---------+------------+--------------------+-----------+----------------------+ +| Type | Source | Queue | Processor | Options | ++---------+------------+--------------------+-----------+----------------------+ +| topic | barTopic | default (prefixed) | processor | array ( | +| | | | | 'bar' => 'barVal', | +| | | | | ) | +| command | fooCommand | default (prefixed) | processor | array ( | +| | | | | 'foo' => 'fooVal', | +| | | | | ) | ++---------+------------+--------------------+-----------+----------------------+ + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface + */ + private function createDriverStub(Config $config, RouteCollection $routeCollection): DriverInterface + { + $driverMock = $this->createMock(DriverInterface::class); + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn($config) + ; + + $driverMock + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection) + ; + + return $driverMock; + } + + private function assertCommandOutput(string $expected, CommandTester $tester): void + { + $this->assertSame(0, $tester->getStatusCode()); + $this->assertSame($expected, $tester->getDisplay()); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/SetupBrokerCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/SetupBrokerCommandTest.php index 5a7a4ab61..c81c4e1b6 100644 --- a/pkg/enqueue/Tests/Symfony/Client/SetupBrokerCommandTest.php +++ b/pkg/enqueue/Tests/Symfony/Client/SetupBrokerCommandTest.php @@ -3,31 +3,74 @@ namespace Enqueue\Tests\Symfony\Client; use Enqueue\Client\DriverInterface; +use Enqueue\Container\Container; use Enqueue\Symfony\Client\SetupBrokerCommand; +use Enqueue\Test\ClassExtensionTrait; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -class SetupBrokerCommandTest extends \PHPUnit_Framework_TestCase +class SetupBrokerCommandTest extends TestCase { - public function testCouldBeConstructedWithRequiredAttributes() + use ClassExtensionTrait; + + public function testShouldBeSubClassOfCommand() { - new \Enqueue\Symfony\Client\SetupBrokerCommand($this->createClientDriverMock()); + $this->assertClassExtends(Command::class, SetupBrokerCommand::class); } - public function testShouldHaveCommandName() + public function testShouldNotBeFinal() { - $command = new SetupBrokerCommand($this->createClientDriverMock()); + $this->assertClassNotFinal(SetupBrokerCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = SetupBrokerCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); - $this->assertEquals('enqueue:setup-broker', $command->getName()); + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:setup-broker', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); } public function testShouldHaveCommandAliases() { - $command = new SetupBrokerCommand($this->createClientDriverMock()); + $command = new SetupBrokerCommand($this->createMock(ContainerInterface::class), 'default'); $this->assertEquals(['enq:sb'], $command->getAliases()); } - public function testShouldCreateQueues() + public function testShouldHaveExpectedOptions() + { + $command = new SetupBrokerCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(1, $options); + $this->assertArrayHasKey('client', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SetupBrokerCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(0, $arguments); + } + + public function testShouldCallDriverSetupBrokerMethod() { $driver = $this->createClientDriverMock(); $driver @@ -35,16 +78,66 @@ public function testShouldCreateQueues() ->method('setupBroker') ; - $command = new SetupBrokerCommand($driver); + $command = new SetupBrokerCommand(new Container([ + 'enqueue.client.default.driver' => $driver, + ]), 'default'); $tester = new CommandTester($command); $tester->execute([]); - $this->assertContains('Setup Broker', $tester->getDisplay()); + $this->assertStringContainsString('Broker set up', $tester->getDisplay()); + } + + public function testShouldCallRequestedClientDriverSetupBrokerMethod() + { + $defaultDriver = $this->createClientDriverMock(); + $defaultDriver + ->expects($this->never()) + ->method('setupBroker') + ; + + $fooDriver = $this->createClientDriverMock(); + $fooDriver + ->expects($this->once()) + ->method('setupBroker') + ; + + $command = new SetupBrokerCommand(new Container([ + 'enqueue.client.default.driver' => $defaultDriver, + 'enqueue.client.foo.driver' => $fooDriver, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + '--client' => 'foo', + ]); + + $this->assertStringContainsString('Broker set up', $tester->getDisplay()); + } + + public function testShouldThrowIfClientNotFound() + { + $defaultDriver = $this->createClientDriverMock(); + $defaultDriver + ->expects($this->never()) + ->method('setupBroker') + ; + + $command = new SetupBrokerCommand(new Container([ + 'enqueue.client.default.driver' => $defaultDriver, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Client "foo" is not supported.'); + $tester->execute([ + '--client' => 'foo', + ]); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|DriverInterface + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface */ private function createClientDriverMock() { diff --git a/pkg/enqueue/Tests/Symfony/Client/SetupBrokerExtensionCommandTraitTest.php b/pkg/enqueue/Tests/Symfony/Client/SetupBrokerExtensionCommandTraitTest.php index 3491b4101..07eb9ea70 100644 --- a/pkg/enqueue/Tests/Symfony/Client/SetupBrokerExtensionCommandTraitTest.php +++ b/pkg/enqueue/Tests/Symfony/Client/SetupBrokerExtensionCommandTraitTest.php @@ -4,9 +4,10 @@ use Enqueue\Client\ConsumptionExtension\SetupBrokerExtension; use Enqueue\Tests\Symfony\Client\Mock\SetupBrokerExtensionCommand; +use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; -class SetupBrokerExtensionCommandTraitTest extends \PHPUnit_Framework_TestCase +class SetupBrokerExtensionCommandTraitTest extends TestCase { public function testShouldAddExtensionOptions() { diff --git a/pkg/enqueue/Tests/Symfony/Client/SimpleConsumeCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/SimpleConsumeCommandTest.php new file mode 100644 index 000000000..21c491eb5 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/SimpleConsumeCommandTest.php @@ -0,0 +1,130 @@ +assertClassExtends(ConsumeCommand::class, SimpleConsumeCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(SimpleConsumeCommand::class); + } + + public function testShouldHaveExpectedOptions() + { + $command = new SimpleConsumeCommand($this->createQueueConsumerMock(), $this->createDriverStub(), $this->createDelegateProcessorMock()); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(9, $options); + $this->assertArrayHasKey('memory-limit', $options); + $this->assertArrayHasKey('message-limit', $options); + $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('receive-timeout', $options); + $this->assertArrayHasKey('niceness', $options); + $this->assertArrayHasKey('client', $options); + $this->assertArrayHasKey('logger', $options); + $this->assertArrayHasKey('skip', $options); + $this->assertArrayHasKey('setup-broker', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SimpleConsumeCommand($this->createQueueConsumerMock(), $this->createDriverStub(), $this->createDelegateProcessorMock()); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(1, $arguments); + $this->assertArrayHasKey('client-queue-names', $arguments); + } + + public function testShouldBindDefaultQueueOnly() + { + $queue = new NullQueue(''); + + $routeCollection = new RouteCollection([]); + + $processor = $this->createDelegateProcessorMock(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with($this->identicalTo($queue), $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $driver = $this->createDriverStub($routeCollection); + $driver + ->expects($this->once()) + ->method('createQueue') + ->with('default', true) + ->willReturn($queue) + ; + + $command = new SimpleConsumeCommand($consumer, $driver, $processor); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DelegateProcessor + */ + private function createDelegateProcessorMock() + { + return $this->createMock(DelegateProcessor::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + private function createQueueConsumerMock() + { + return $this->createMock(QueueConsumerInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface + */ + private function createDriverStub(?RouteCollection $routeCollection = null): DriverInterface + { + $driverMock = $this->createMock(DriverInterface::class); + $driverMock + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection ?? new RouteCollection([])) + ; + + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn(Config::create('aPrefix', 'anApp')) + ; + + return $driverMock; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/SimpleProduceCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/SimpleProduceCommandTest.php new file mode 100644 index 000000000..3ff81bfd5 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/SimpleProduceCommandTest.php @@ -0,0 +1,78 @@ +assertClassExtends(ProduceCommand::class, SimpleProduceCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(SimpleProduceCommand::class); + } + + public function testShouldHaveExpectedOptions() + { + $command = new SimpleProduceCommand($this->createProducerMock()); + + $options = $command->getDefinition()->getOptions(); + $this->assertCount(4, $options); + $this->assertArrayHasKey('client', $options); + $this->assertArrayHasKey('topic', $options); + $this->assertArrayHasKey('command', $options); + $this->assertArrayHasKey('header', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SimpleProduceCommand($this->createProducerMock()); + + $arguments = $command->getDefinition()->getArguments(); + $this->assertCount(1, $arguments); + + $this->assertArrayHasKey('message', $arguments); + } + + public function testThrowIfNeitherTopicNorCommandOptionsAreSet() + { + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->never()) + ->method('sendEvent') + ; + $producerMock + ->expects($this->never()) + ->method('sendCommand') + ; + + $command = new SimpleProduceCommand($producerMock); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Either topic or command option should be set, none is set.'); + $tester->execute([ + 'message' => 'theMessage', + ]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerInterface + */ + private function createProducerMock() + { + return $this->createMock(ProducerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/SimpleRoutesCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/SimpleRoutesCommandTest.php new file mode 100644 index 000000000..20ee454cc --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/SimpleRoutesCommandTest.php @@ -0,0 +1,107 @@ +assertClassExtends(RoutesCommand::class, SimpleRoutesCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(SimpleRoutesCommand::class); + } + + public function testShouldHaveCommandAliases() + { + $command = new SimpleRoutesCommand($this->createDriverMock()); + + $this->assertEquals(['debug:enqueue:routes'], $command->getAliases()); + } + + public function testShouldHaveExpectedOptions() + { + $command = new SimpleRoutesCommand($this->createDriverMock()); + + $options = $command->getDefinition()->getOptions(); + $this->assertCount(2, $options); + + $this->assertArrayHasKey('show-route-options', $options); + $this->assertArrayHasKey('client', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SimpleRoutesCommand($this->createDriverMock()); + + $arguments = $command->getDefinition()->getArguments(); + $this->assertCount(0, $arguments); + } + + public function testShouldOutputEmptyRouteCollection() + { + $routeCollection = new RouteCollection([]); + + $command = new SimpleRoutesCommand($this->createDriverStub(Config::create(), $routeCollection)); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $expectedOutput = <<<'OUTPUT' +Found 0 routes + + +OUTPUT; + + $this->assertCommandOutput($expectedOutput, $tester); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverMock(): DriverInterface + { + return $this->createMock(DriverInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createDriverStub(Config $config, RouteCollection $routeCollection): DriverInterface + { + $driverMock = $this->createDriverMock(); + $driverMock + ->expects($this->any()) + ->method('getConfig') + ->willReturn($config) + ; + + $driverMock + ->expects($this->any()) + ->method('getRouteCollection') + ->willReturn($routeCollection) + ; + + return $driverMock; + } + + private function assertCommandOutput(string $expected, CommandTester $tester): void + { + $this->assertSame(0, $tester->getStatusCode()); + $this->assertSame($expected, $tester->getDisplay()); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Client/SimpleSetupBrokerCommandTest.php b/pkg/enqueue/Tests/Symfony/Client/SimpleSetupBrokerCommandTest.php new file mode 100644 index 000000000..3702dbf18 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Client/SimpleSetupBrokerCommandTest.php @@ -0,0 +1,75 @@ +assertClassExtends(SetupBrokerCommand::class, SimpleSetupBrokerCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(SimpleSetupBrokerCommand::class); + } + + public function testShouldHaveCommandAliases() + { + $command = new SimpleSetupBrokerCommand($this->createClientDriverMock()); + + $this->assertEquals(['enq:sb'], $command->getAliases()); + } + + public function testShouldHaveExpectedOptions() + { + $command = new SimpleSetupBrokerCommand($this->createClientDriverMock()); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(1, $options); + $this->assertArrayHasKey('client', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SimpleSetupBrokerCommand($this->createClientDriverMock()); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(0, $arguments); + } + + public function testShouldCallDriverSetupBrokerMethod() + { + $driver = $this->createClientDriverMock(); + $driver + ->expects($this->once()) + ->method('setupBroker') + ; + + $command = new SimpleSetupBrokerCommand($driver); + + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertStringContainsString('Broker set up', $tester->getDisplay()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|DriverInterface + */ + private function createClientDriverMock() + { + return $this->createMock(DriverInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/ConfigurableConsumeCommandTest.php b/pkg/enqueue/Tests/Symfony/Consumption/ConfigurableConsumeCommandTest.php new file mode 100644 index 000000000..251e264e2 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Consumption/ConfigurableConsumeCommandTest.php @@ -0,0 +1,306 @@ +assertClassExtends(Command::class, ConfigurableConsumeCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(ConfigurableConsumeCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = ConfigurableConsumeCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); + + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:transport:consume', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); + } + + public function testShouldHaveExpectedOptions() + { + $command = new ConfigurableConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(7, $options); + $this->assertArrayHasKey('memory-limit', $options); + $this->assertArrayHasKey('message-limit', $options); + $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('receive-timeout', $options); + $this->assertArrayHasKey('niceness', $options); + $this->assertArrayHasKey('transport', $options); + $this->assertArrayHasKey('logger', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new ConfigurableConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(2, $arguments); + $this->assertArrayHasKey('processor', $arguments); + $this->assertArrayHasKey('queues', $arguments); + } + + public function testThrowIfNeitherQueueOptionNorProcessorImplementsQueueSubscriberInterface() + { + $processor = $this->createProcessor(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->never()) + ->method('bind') + ; + $consumer + ->expects($this->never()) + ->method('consume') + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + 'enqueue.transport.default.processor_registry' => new ArrayProcessorRegistry(['aProcessor' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The queue is not provided. The processor must implement "Enqueue\Consumption\QueueSubscriberInterface" interface and it must return not empty array of queues or a queue set using as a second argument.'); + $tester->execute([ + 'processor' => 'aProcessor', + ]); + } + + public function testShouldExecuteConsumptionWithExplicitlySetQueue() + { + $processor = $this->createProcessor(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('bind') + ->with('queue-name', $this->identicalTo($processor)) + ; + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + 'enqueue.transport.default.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'processor' => 'processor-service', + 'queues' => ['queue-name'], + ]); + } + + public function testThrowIfTransportNotDefined() + { + $processor = $this->createProcessor(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->never()) + ->method('bind') + ; + $consumer + ->expects($this->never()) + ->method('consume') + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + 'enqueue.transport.default.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Transport "not-defined" is not supported.'); + $tester->execute([ + 'processor' => 'processor-service', + 'queues' => ['queue-name'], + '--transport' => 'not-defined', + ]); + } + + public function testShouldExecuteConsumptionWithSeveralCustomQueues() + { + $processor = $this->createProcessor(); + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->at(0)) + ->method('bind') + ->with('queue-name', $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(1)) + ->method('bind') + ->with('another-queue-name', $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(2)) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + 'enqueue.transport.default.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'processor' => 'processor-service', + 'queues' => ['queue-name', 'another-queue-name'], + ]); + } + + public function testShouldExecuteConsumptionWhenProcessorImplementsQueueSubscriberInterface() + { + $processor = new class implements Processor, QueueSubscriberInterface { + public function process(InteropMessage $message, Context $context): void + { + } + + public static function getSubscribedQueues() + { + return ['fooSubscribedQueues', 'barSubscribedQueues']; + } + }; + + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->at(0)) + ->method('bind') + ->with('fooSubscribedQueues', $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(1)) + ->method('bind') + ->with('barSubscribedQueues', $this->identicalTo($processor)) + ; + $consumer + ->expects($this->at(2)) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + 'enqueue.transport.default.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'processor' => 'processor-service', + ]); + } + + public function testShouldExecuteConsumptionWithCustomTransportExplicitlySetQueue() + { + $processor = $this->createProcessor(); + + $fooConsumer = $this->createQueueConsumerMock(); + $fooConsumer + ->expects($this->never()) + ->method('bind') + ; + $fooConsumer + ->expects($this->never()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $barConsumer = $this->createQueueConsumerMock(); + $barConsumer + ->expects($this->once()) + ->method('bind') + ->with('queue-name', $this->identicalTo($processor)) + ; + $barConsumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConfigurableConsumeCommand(new Container([ + 'enqueue.transport.foo.queue_consumer' => $fooConsumer, + 'enqueue.transport.foo.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + 'enqueue.transport.bar.queue_consumer' => $barConsumer, + 'enqueue.transport.bar.processor_registry' => new ArrayProcessorRegistry(['processor-service' => $processor]), + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([ + 'processor' => 'processor-service', + 'queues' => ['queue-name'], + '--transport' => 'bar', + ]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|InteropQueue + */ + protected function createQueueMock() + { + return $this->createMock(InteropQueue::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Processor + */ + protected function createProcessor() + { + return $this->createMock(Processor::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + protected function createQueueConsumerMock() + { + return $this->createMock(QueueConsumerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/ConsumeCommandTest.php b/pkg/enqueue/Tests/Symfony/Consumption/ConsumeCommandTest.php new file mode 100644 index 000000000..f07bef03b --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Consumption/ConsumeCommandTest.php @@ -0,0 +1,247 @@ +assertClassExtends(Command::class, ConsumeCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(ConsumeCommand::class); + } + + public function testShouldHaveAsCommandAttributeWithCommandName() + { + $commandClass = ConsumeCommand::class; + + $reflectionClass = new \ReflectionClass($commandClass); + + $attributes = $reflectionClass->getAttributes(AsCommand::class); + + $this->assertNotEmpty($attributes, 'The command does not have the AsCommand attribute.'); + + // Get the first attribute instance (assuming there is only one AsCommand attribute) + $asCommandAttribute = $attributes[0]; + + // Verify the 'name' parameter value + $attributeInstance = $asCommandAttribute->newInstance(); + $this->assertEquals('enqueue:transport:consume', $attributeInstance->name, 'The command name is not set correctly in the AsCommand attribute.'); + } + + public function testShouldHaveExpectedOptions() + { + $command = new ConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(7, $options); + $this->assertArrayHasKey('memory-limit', $options); + $this->assertArrayHasKey('message-limit', $options); + $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('receive-timeout', $options); + $this->assertArrayHasKey('niceness', $options); + $this->assertArrayHasKey('transport', $options); + $this->assertArrayHasKey('logger', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new ConsumeCommand($this->createMock(ContainerInterface::class), 'default'); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(0, $arguments); + } + + public function testShouldExecuteDefaultConsumption() + { + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + public function testShouldExecuteCustomConsumption() + { + $defaultConsumer = $this->createQueueConsumerMock(); + $defaultConsumer + ->expects($this->never()) + ->method('consume') + ; + + $customConsumer = $this->createQueueConsumerMock(); + $customConsumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $defaultConsumer, + 'enqueue.transport.custom.queue_consumer' => $customConsumer, + ]), 'default'); + + $tester = new CommandTester($command); + $tester->execute(['--transport' => 'custom']); + } + + public function testThrowIfNotDefinedTransportRequested() + { + $defaultConsumer = $this->createQueueConsumerMock(); + $defaultConsumer + ->expects($this->never()) + ->method('consume') + ; + + $command = new ConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $defaultConsumer, + ]), 'default'); + + $tester = new CommandTester($command); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Transport "not-defined" is not supported.'); + $tester->execute(['--transport' => 'not-defined']); + } + + public function testShouldReturnExitStatusIfSet() + { + $testExitCode = 678; + + $stubExtension = $this->createExtension(); + + $stubExtension + ->expects($this->once()) + ->method('onStart') + ->with($this->isInstanceOf(Start::class)) + ->willReturnCallback(function (Start $context) use ($testExitCode) { + $context->interruptExecution($testExitCode); + }) + ; + + $consumer = new QueueConsumer($this->createContextStub(), $stubExtension); + + $command = new ConsumeCommand(new Container([ + 'enqueue.transport.default.queue_consumer' => $consumer, + ]), 'default'); + + $tester = new CommandTester($command); + + $tester->execute([]); + + $this->assertEquals($testExitCode, $tester->getStatusCode()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + private function createQueueConsumerMock() + { + return $this->createMock(QueueConsumerInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createContextWithoutSubscriptionConsumerMock(): InteropContext + { + $contextMock = $this->createMock(InteropContext::class); + $contextMock + ->expects($this->any()) + ->method('createSubscriptionConsumer') + ->willThrowException(SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt()) + ; + + return $contextMock; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|InteropContext + */ + private function createContextStub(?Consumer $consumer = null): InteropContext + { + $context = $this->createContextWithoutSubscriptionConsumerMock(); + $context + ->expects($this->any()) + ->method('createQueue') + ->willReturnCallback(function (string $queueName) { + return new NullQueue($queueName); + }) + ; + $context + ->expects($this->any()) + ->method('createConsumer') + ->willReturnCallback(function (Queue $queue) use ($consumer) { + return $consumer ?: $this->createConsumerStub($queue); + }) + ; + + return $context; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ExtensionInterface + */ + private function createExtension() + { + return $this->createMock(ExtensionInterface::class); + } + + /** + * @param mixed|null $queue + * + * @return \PHPUnit\Framework\MockObject\MockObject|Consumer + */ + private function createConsumerStub($queue = null): Consumer + { + if (null === $queue) { + $queue = 'queue'; + } + if (is_string($queue)) { + $queue = new NullQueue($queue); + } + + $consumerMock = $this->createMock(Consumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queue) + ; + + return $consumerMock; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/ConsumeMessagesCommandTest.php b/pkg/enqueue/Tests/Symfony/Consumption/ConsumeMessagesCommandTest.php deleted file mode 100644 index a3bd6e05c..000000000 --- a/pkg/enqueue/Tests/Symfony/Consumption/ConsumeMessagesCommandTest.php +++ /dev/null @@ -1,87 +0,0 @@ -createQueueConsumerMock()); - } - - public function testShouldHaveCommandName() - { - $command = new ConsumeMessagesCommand($this->createQueueConsumerMock()); - - $this->assertEquals('enqueue:transport:consume', $command->getName()); - } - - public function testShouldHaveExpectedOptions() - { - $command = new ConsumeMessagesCommand($this->createQueueConsumerMock()); - - $options = $command->getDefinition()->getOptions(); - - $this->assertCount(3, $options); - $this->assertArrayHasKey('memory-limit', $options); - $this->assertArrayHasKey('message-limit', $options); - $this->assertArrayHasKey('time-limit', $options); - } - - public function testShouldHaveExpectedAttributes() - { - $command = new ConsumeMessagesCommand($this->createQueueConsumerMock()); - - $arguments = $command->getDefinition()->getArguments(); - - $this->assertCount(0, $arguments); - } - - public function testShouldExecuteConsumption() - { - $context = $this->createContextMock(); - $context - ->expects($this->once()) - ->method('close') - ; - - $consumer = $this->createQueueConsumerMock(); - $consumer - ->expects($this->once()) - ->method('consume') - ->with($this->isInstanceOf(ChainExtension::class)) - ; - $consumer - ->expects($this->once()) - ->method('getPsrContext') - ->will($this->returnValue($context)) - ; - - $command = new ConsumeMessagesCommand($consumer); - - $tester = new CommandTester($command); - $tester->execute([]); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context - */ - private function createContextMock() - { - return $this->createMock(Context::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueConsumer - */ - private function createQueueConsumerMock() - { - return $this->createMock(QueueConsumer::class); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/ContainerAwareConsumeMessagesCommandTest.php b/pkg/enqueue/Tests/Symfony/Consumption/ContainerAwareConsumeMessagesCommandTest.php deleted file mode 100644 index f5262aec4..000000000 --- a/pkg/enqueue/Tests/Symfony/Consumption/ContainerAwareConsumeMessagesCommandTest.php +++ /dev/null @@ -1,147 +0,0 @@ -createQueueConsumerMock()); - } - - public function testShouldHaveCommandName() - { - $command = new ContainerAwareConsumeMessagesCommand($this->createQueueConsumerMock()); - - $this->assertEquals('enqueue:transport:consume', $command->getName()); - } - - public function testShouldHaveExpectedOptions() - { - $command = new ContainerAwareConsumeMessagesCommand($this->createQueueConsumerMock()); - - $options = $command->getDefinition()->getOptions(); - - $this->assertCount(3, $options); - $this->assertArrayHasKey('memory-limit', $options); - $this->assertArrayHasKey('message-limit', $options); - $this->assertArrayHasKey('time-limit', $options); - } - - public function testShouldHaveExpectedAttributes() - { - $command = new ContainerAwareConsumeMessagesCommand($this->createQueueConsumerMock()); - - $arguments = $command->getDefinition()->getArguments(); - - $this->assertCount(2, $arguments); - $this->assertArrayHasKey('processor-service', $arguments); - $this->assertArrayHasKey('queue', $arguments); - } - - public function testShouldThrowExceptionIfProcessorInstanceHasWrongClass() - { - $this->setExpectedException(\LogicException::class, 'Invalid message processor service given.'. - ' It must be an instance of Enqueue\Psr\Processor but stdClass'); - - $container = new Container(); - $container->set('processor-service', new \stdClass()); - - $command = new ContainerAwareConsumeMessagesCommand($this->createQueueConsumerMock()); - $command->setContainer($container); - - $tester = new CommandTester($command); - $tester->execute([ - 'queue' => 'queue-name', - 'processor-service' => 'processor-service', - ]); - } - - public function testShouldExecuteConsumption() - { - $processor = $this->createProcessor(); - - $queue = $this->createQueueMock(); - - $context = $this->createContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('close') - ; - - $consumer = $this->createQueueConsumerMock(); - $consumer - ->expects($this->once()) - ->method('bind') - ->with($this->identicalTo($queue), $this->identicalTo($processor)) - ; - $consumer - ->expects($this->once()) - ->method('consume') - ->with($this->isInstanceOf(ChainExtension::class)) - ; - $consumer - ->expects($this->exactly(2)) - ->method('getPsrContext') - ->will($this->returnValue($context)) - ; - - $container = new Container(); - $container->set('processor-service', $processor); - - $command = new ContainerAwareConsumeMessagesCommand($consumer); - $command->setContainer($container); - - $tester = new CommandTester($command); - $tester->execute([ - 'queue' => 'queue-name', - 'processor-service' => 'processor-service', - ]); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context - */ - protected function createContextMock() - { - return $this->createMock(Context::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Queue - */ - protected function createQueueMock() - { - return $this->createMock(Queue::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Processor - */ - protected function createProcessor() - { - return $this->createMock(Processor::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueConsumer - */ - protected function createQueueConsumerMock() - { - return $this->createMock(QueueConsumer::class); - } -} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/LimitsExtensionsCommandTraitTest.php b/pkg/enqueue/Tests/Symfony/Consumption/LimitsExtensionsCommandTraitTest.php index 90293d094..f47a32161 100644 --- a/pkg/enqueue/Tests/Symfony/Consumption/LimitsExtensionsCommandTraitTest.php +++ b/pkg/enqueue/Tests/Symfony/Consumption/LimitsExtensionsCommandTraitTest.php @@ -5,10 +5,12 @@ use Enqueue\Consumption\Extension\LimitConsumedMessagesExtension; use Enqueue\Consumption\Extension\LimitConsumerMemoryExtension; use Enqueue\Consumption\Extension\LimitConsumptionTimeExtension; +use Enqueue\Consumption\Extension\NicenessExtension; use Enqueue\Tests\Symfony\Consumption\Mock\LimitsExtensionsCommand; +use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; -class LimitsExtensionsCommandTraitTest extends \PHPUnit_Framework_TestCase +class LimitsExtensionsCommandTraitTest extends TestCase { public function testShouldAddExtensionsOptions() { @@ -16,10 +18,11 @@ public function testShouldAddExtensionsOptions() $options = $trait->getDefinition()->getOptions(); - $this->assertCount(3, $options); + $this->assertCount(4, $options); $this->assertArrayHasKey('memory-limit', $options); $this->assertArrayHasKey('message-limit', $options); $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('niceness', $options); } public function testShouldAddMessageLimitExtension() @@ -56,7 +59,8 @@ public function testShouldAddTimeLimitExtension() public function testShouldThrowExceptionIfTimeLimitExpressionIsNotValid() { - $this->setExpectedException(\Exception::class, 'Failed to parse time string (time is not valid) at position'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Failed to parse time string (time is not valid) at position'); $command = new LimitsExtensionsCommand('name'); @@ -103,4 +107,36 @@ public function testShouldAddThreeLimitExtensions() $this->assertInstanceOf(LimitConsumptionTimeExtension::class, $result[1]); $this->assertInstanceOf(LimitConsumerMemoryExtension::class, $result[2]); } + + /** + * @dataProvider provideNicenessValues + */ + public function testShouldAddNicenessExtension($inputValue, bool $enabled) + { + $command = new LimitsExtensionsCommand('name'); + $tester = new CommandTester($command); + $tester->execute([ + '--niceness' => $inputValue, + ]); + + $result = $command->getExtensions(); + + if ($enabled) { + $this->assertCount(1, $result); + $this->assertInstanceOf(NicenessExtension::class, $result[0]); + } else { + $this->assertEmpty($result); + } + } + + public function provideNicenessValues(): \Generator + { + yield [1, true]; + yield ['1', true]; + yield [-1.0, true]; + yield ['100', true]; + yield ['', false]; + yield ['0', false]; + yield [0.0, false]; + } } diff --git a/pkg/enqueue/Tests/Symfony/Consumption/Mock/LimitsExtensionsCommand.php b/pkg/enqueue/Tests/Symfony/Consumption/Mock/LimitsExtensionsCommand.php index 7b6722393..05e0c56ba 100644 --- a/pkg/enqueue/Tests/Symfony/Consumption/Mock/LimitsExtensionsCommand.php +++ b/pkg/enqueue/Tests/Symfony/Consumption/Mock/LimitsExtensionsCommand.php @@ -25,8 +25,10 @@ protected function configure() $this->configureLimitsExtensions(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $this->extensions = $this->getLimitsExtensions($input, $output); + + return 0; } } diff --git a/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueConsumerOptionsCommand.php b/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueConsumerOptionsCommand.php new file mode 100644 index 000000000..147a3b905 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueConsumerOptionsCommand.php @@ -0,0 +1,40 @@ +consumer = $consumer; + } + + protected function configure() + { + parent::configure(); + + $this->configureQueueConsumerOptions(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->setQueueConsumerOptions($this->consumer, $input); + + return 0; + } +} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueSubscriberProcessor.php b/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueSubscriberProcessor.php new file mode 100644 index 000000000..a210b0e6b --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Consumption/Mock/QueueSubscriberProcessor.php @@ -0,0 +1,21 @@ +createQueueConsumer()); + + $options = $trait->getDefinition()->getOptions(); + + $this->assertCount(1, $options); + $this->assertArrayHasKey('receive-timeout', $options); + } + + public function testShouldSetQueueConsumerOptions() + { + $consumer = $this->createQueueConsumer(); + $consumer + ->expects($this->once()) + ->method('setReceiveTimeout') + ->with($this->identicalTo(456)) + ; + + $trait = new QueueConsumerOptionsCommand($consumer); + + $tester = new CommandTester($trait); + $tester->execute([ + '--receive-timeout' => '456', + ]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + private function createQueueConsumer() + { + return $this->createMock(QueueConsumerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/Consumption/SimpleConsumeCommandTest.php b/pkg/enqueue/Tests/Symfony/Consumption/SimpleConsumeCommandTest.php new file mode 100644 index 000000000..eeb38bf19 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/Consumption/SimpleConsumeCommandTest.php @@ -0,0 +1,74 @@ +assertClassExtends(ConsumeCommand::class, SimpleConsumeCommand::class); + } + + public function testShouldNotBeFinal() + { + $this->assertClassNotFinal(SimpleConsumeCommand::class); + } + + public function testShouldHaveExpectedOptions() + { + $command = new SimpleConsumeCommand($this->createQueueConsumerMock()); + + $options = $command->getDefinition()->getOptions(); + + $this->assertCount(7, $options); + $this->assertArrayHasKey('memory-limit', $options); + $this->assertArrayHasKey('message-limit', $options); + $this->assertArrayHasKey('time-limit', $options); + $this->assertArrayHasKey('receive-timeout', $options); + $this->assertArrayHasKey('niceness', $options); + $this->assertArrayHasKey('transport', $options); + $this->assertArrayHasKey('logger', $options); + } + + public function testShouldHaveExpectedAttributes() + { + $command = new SimpleConsumeCommand($this->createQueueConsumerMock()); + + $arguments = $command->getDefinition()->getArguments(); + + $this->assertCount(0, $arguments); + } + + public function testShouldExecuteDefaultConsumption() + { + $consumer = $this->createQueueConsumerMock(); + $consumer + ->expects($this->once()) + ->method('consume') + ->with($this->isInstanceOf(ChainExtension::class)) + ; + + $command = new SimpleConsumeCommand($consumer); + + $tester = new CommandTester($command); + $tester->execute([]); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|QueueConsumerInterface + */ + private function createQueueConsumerMock() + { + return $this->createMock(QueueConsumerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/ContainerProcessorRegistryTest.php b/pkg/enqueue/Tests/Symfony/ContainerProcessorRegistryTest.php new file mode 100644 index 000000000..5504e8ef6 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/ContainerProcessorRegistryTest.php @@ -0,0 +1,107 @@ +assertClassImplements(ProcessorRegistryInterface::class, ContainerProcessorRegistry::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(ContainerProcessorRegistry::class); + } + + public function testShouldAllowGetProcessor() + { + $processorMock = $this->createProcessorMock(); + + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('has') + ->with('processor-name') + ->willReturn(true) + ; + $containerMock + ->expects($this->once()) + ->method('get') + ->with('processor-name') + ->willReturn($processorMock) + ; + + $registry = new ContainerProcessorRegistry($containerMock); + $this->assertSame($processorMock, $registry->get('processor-name')); + } + + public function testThrowErrorIfServiceDoesNotImplementProcessorReturnType() + { + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('has') + ->with('processor-name') + ->willReturn(true) + ; + $containerMock + ->expects($this->once()) + ->method('get') + ->with('processor-name') + ->willReturn(new \stdClass()) + ; + + $registry = new ContainerProcessorRegistry($containerMock); + + $this->expectException(\TypeError::class); + // Exception messages vary slightly between versions + $this->expectExceptionMessageMatches( + '/Enqueue\\\\Symfony\\\\ContainerProcessorRegistry::get\(\).+ Interop\\\\Queue\\\\Processor,.*stdClass returned/' + ); + + $registry->get('processor-name'); + } + + public function testShouldThrowExceptionIfProcessorIsNotSet() + { + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('has') + ->with('processor-name') + ->willReturn(false) + ; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service locator does not have a processor with name "processor-name".'); + + $registry = new ContainerProcessorRegistry($containerMock); + $registry->get('processor-name'); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createProcessorMock(): Processor + { + return $this->createMock(Processor::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject + */ + private function createContainerMock(): ContainerInterface + { + return $this->createMock(ContainerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/DefaultTransportFactoryTest.php b/pkg/enqueue/Tests/Symfony/DefaultTransportFactoryTest.php deleted file mode 100644 index 917ad5d2b..000000000 --- a/pkg/enqueue/Tests/Symfony/DefaultTransportFactoryTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, DefaultTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new DefaultTransportFactory(); - - $this->assertEquals('default', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new DefaultTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new DefaultTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), ['the_alias']); - - $this->assertEquals(['alias' => 'the_alias'], $config); - } - - public function testShouldCreateContext() - { - $container = new ContainerBuilder(); - - $transport = new DefaultTransportFactory(); - - $serviceId = $transport->createContext($container, ['alias' => 'the_alias']); - - $this->assertEquals('enqueue.transport.default.context', $serviceId); - - $this->assertTrue($container->hasAlias($serviceId)); - $context = $container->getAlias($serviceId); - $this->assertEquals('enqueue.transport.the_alias.context', (string) $context); - - $this->assertTrue($container->hasAlias('enqueue.transport.context')); - $context = $container->getAlias('enqueue.transport.context'); - $this->assertEquals($serviceId, (string) $context); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new DefaultTransportFactory(); - - $driverId = $transport->createDriver($container, ['alias' => 'the_alias']); - - $this->assertEquals('enqueue.client.default.driver', $driverId); - - $this->assertTrue($container->hasAlias($driverId)); - $context = $container->getAlias($driverId); - $this->assertEquals('enqueue.client.the_alias.driver', (string) $context); - - $this->assertTrue($container->hasAlias('enqueue.client.driver')); - $context = $container->getAlias('enqueue.client.driver'); - $this->assertEquals($driverId, (string) $context); - } -} diff --git a/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildConsumptionExtensionsPassTest.php b/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildConsumptionExtensionsPassTest.php new file mode 100644 index 000000000..bdccd338c --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildConsumptionExtensionsPassTest.php @@ -0,0 +1,283 @@ +assertClassImplements(CompilerPassInterface::class, BuildConsumptionExtensionsPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildConsumptionExtensionsPass::class); + } + + public function testThrowIfEnqueueTransportsParameterNotSet() + { + $pass = new BuildConsumptionExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.transports" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoConsumptionExtensionsServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo', 'bar']); + $container->setParameter('enqueue.default_transport', 'foo'); + + $pass = new BuildConsumptionExtensionsPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.transport.foo.consumption_extensions" not found'); + $pass->process($container); + } + + public function testShouldRegisterTransportExtension() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'foo']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldIgnoreOtherTransportExtensions() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'anotherName']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldAddExtensionIfTransportAll() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'all']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'anotherName']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldTreatTagsWithoutTransportAsDefaultTransport() + { + $extensions = new Definition(); + $extensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension') + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + new Reference('aBarExtension'), + ], $extensions->getArgument(0)); + } + + public function testShouldOrderExtensionsByPriority() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension', ['priority' => 6]); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension', ['priority' => -5]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension', ['priority' => 2]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[2]); + } + + public function testShouldAssumePriorityZeroIfPriorityIsNotSet() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + + $extensions = new Definition(); + $extensions->addArgument([]); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension'); + $container->setDefinition('foo_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension', ['priority' => 1]); + $container->setDefinition('bar_extension', $extension); + + $extension = new Definition(); + $extension->addTag('enqueue.transport.consumption_extension', ['priority' => -1]); + $container->setDefinition('baz_extension', $extension); + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + $orderedExtensions = $extensions->getArgument(0); + + $this->assertCount(3, $orderedExtensions); + $this->assertEquals(new Reference('bar_extension'), $orderedExtensions[0]); + $this->assertEquals(new Reference('foo_extension'), $orderedExtensions[1]); + $this->assertEquals(new Reference('baz_extension'), $orderedExtensions[2]); + } + + public function testShouldMergeWithAddedPreviously() + { + $extensions = new Definition(); + $extensions->addArgument([ + 'aBarExtension' => 'aBarServiceIdAddedPreviously', + 'aOloloExtension' => 'aOloloServiceIdAddedPreviously', + ]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $extensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension') + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension') + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($extensions->getArgument(0)); + $this->assertCount(4, $extensions->getArgument(0)); + } + + public function testShouldRegisterProcessorWithMatchedNameToCorrespondingRegistries() + { + $fooExtensions = new Definition(); + $fooExtensions->addArgument([]); + + $barExtensions = new Definition(); + $barExtensions->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo', 'bar']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.consumption_extensions', $fooExtensions); + $container->setDefinition('enqueue.transport.bar.consumption_extensions', $barExtensions); + + $container->register('aFooExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'foo']) + ; + $container->register('aBarExtension', ExtensionInterface::class) + ->addTag('enqueue.transport.consumption_extension', ['transport' => 'bar']) + ; + + $pass = new BuildConsumptionExtensionsPass(); + $pass->process($container); + + self::assertIsArray($fooExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aFooExtension'), + ], $fooExtensions->getArgument(0)); + + self::assertIsArray($barExtensions->getArgument(0)); + $this->assertEquals([ + new Reference('aBarExtension'), + ], $barExtensions->getArgument(0)); + } +} diff --git a/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildProcessorRegistryPassTest.php b/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildProcessorRegistryPassTest.php new file mode 100644 index 000000000..134c216dc --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/DependencyInjection/BuildProcessorRegistryPassTest.php @@ -0,0 +1,214 @@ +assertClassImplements(CompilerPassInterface::class, BuildProcessorRegistryPass::class); + } + + public function testShouldBeFinal() + { + $this->assertClassFinal(BuildProcessorRegistryPass::class); + } + + public function testThrowIfEnqueueTransportsParameterNotSet() + { + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "enqueue.transports" parameter must be set.'); + $pass->process(new ContainerBuilder()); + } + + public function testThrowsIfNoRegistryServiceFoundForConfiguredTransport() + { + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo', 'bar']); + $container->setParameter('enqueue.default_transport', 'baz'); + + $pass = new BuildProcessorRegistryPass(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Service "enqueue.transport.foo.processor_registry" not found'); + $pass->process($container); + } + + public function testShouldRegisterProcessorWithMatchedName() + { + $registry = new Definition(ProcessorRegistryInterface::class); + $registry->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.processor_registry', $registry); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'foo']) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'bar']) + ; + + $pass = new BuildProcessorRegistryPass(); + + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $registry->getArgument(0)); + + $this->assertLocatorServices($container, $registry->getArgument(0), [ + 'aFooProcessor' => 'aFooProcessor', + ]); + } + + public function testShouldRegisterProcessorWithMatchedNameToCorrespondingRegistries() + { + $fooRegistry = new Definition(ProcessorRegistryInterface::class); + $fooRegistry->addArgument([]); + + $barRegistry = new Definition(ProcessorRegistryInterface::class); + $barRegistry->addArgument([]); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo', 'bar']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.processor_registry', $fooRegistry); + $container->setDefinition('enqueue.transport.bar.processor_registry', $barRegistry); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'foo']) + ; + $container->register('aBarProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'bar']) + ; + + $pass = new BuildProcessorRegistryPass(); + + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $fooRegistry->getArgument(0)); + $this->assertLocatorServices($container, $fooRegistry->getArgument(0), [ + 'aFooProcessor' => 'aFooProcessor', + ]); + + $this->assertInstanceOf(Reference::class, $barRegistry->getArgument(0)); + $this->assertLocatorServices($container, $barRegistry->getArgument(0), [ + 'aBarProcessor' => 'aBarProcessor', + ]); + } + + public function testShouldRegisterProcessorWithoutNameToDefaultTransport() + { + $registry = new Definition(ProcessorRegistryInterface::class); + $registry->addArgument(null); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.processor_registry', $registry); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', []) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'bar']) + ; + + $pass = new BuildProcessorRegistryPass(); + + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $registry->getArgument(0)); + + $this->assertLocatorServices($container, $registry->getArgument(0), [ + 'aFooProcessor' => 'aFooProcessor', + ]); + } + + public function testShouldRegisterProcessorIfTransportNameEqualsAll() + { + $registry = new Definition(ProcessorRegistryInterface::class); + $registry->addArgument(null); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.processor_registry', $registry); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'all']) + ; + $container->register('aProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['transport' => 'bar']) + ; + + $pass = new BuildProcessorRegistryPass(); + + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $registry->getArgument(0)); + + $this->assertLocatorServices($container, $registry->getArgument(0), [ + 'aFooProcessor' => 'aFooProcessor', + ]); + } + + public function testShouldRegisterWithCustomProcessorName() + { + $registry = new Definition(ProcessorRegistryInterface::class); + $registry->addArgument(null); + + $container = new ContainerBuilder(); + $container->setParameter('enqueue.transports', ['foo']); + $container->setParameter('enqueue.default_transport', 'foo'); + $container->setDefinition('enqueue.transport.foo.processor_registry', $registry); + $container->register('aFooProcessor', 'aProcessorClass') + ->addTag('enqueue.transport.processor', ['processor' => 'customProcessorName']) + ; + + $pass = new BuildProcessorRegistryPass(); + + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $registry->getArgument(0)); + + $this->assertLocatorServices($container, $registry->getArgument(0), [ + 'customProcessorName' => 'aFooProcessor', + ]); + } + + private function assertLocatorServices(ContainerBuilder $container, $locatorId, array $locatorServices) + { + $this->assertInstanceOf(Reference::class, $locatorId); + $locatorId = (string) $locatorId; + + $this->assertTrue($container->hasDefinition($locatorId)); + $this->assertMatchesRegularExpression('/\.?service_locator\..*?\.enqueue\./', $locatorId); + + $match = []; + if (false == preg_match('/(\.?service_locator\..*?)\.enqueue\./', $locatorId, $match)) { + $this->fail('preg_match should not failed'); + } + + $this->assertTrue($container->hasDefinition($match[1])); + $locator = $container->getDefinition($match[1]); + + $this->assertContainsOnly(ServiceClosureArgument::class, $locator->getArgument(0)); + $actualServices = array_map(function (ServiceClosureArgument $value) { + return (string) $value->getValues()[0]; + }, $locator->getArgument(0)); + + $this->assertEquals($locatorServices, $actualServices); + } +} diff --git a/pkg/enqueue/Tests/Symfony/DependencyInjection/TransportFactoryTest.php b/pkg/enqueue/Tests/Symfony/DependencyInjection/TransportFactoryTest.php new file mode 100644 index 000000000..909407452 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/DependencyInjection/TransportFactoryTest.php @@ -0,0 +1,478 @@ +assertClassFinal(TransportFactory::class); + } + + public function testThrowIfEmptyNameGivenOnConstruction() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The name could not be empty.'); + + new TransportFactory(''); + } + + public function testShouldAllowAddConfigurationAsStringDsn() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $config = $processor->process($tb->buildTree(), [['transport' => 'dsn://']]); + + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'dsn://', + ], + ], $config); + } + + /** + * @see https://github.com/php-enqueue/enqueue-dev/issues/356 + * + * @group bug + */ + public function testShouldAllowAddConfigurationAsDsnWithoutSlashes() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $config = $processor->process($tb->buildTree(), [['transport' => 'dsn:']]); + + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'dsn:', + ], + ], $config); + } + + public function testShouldSetNullTransportIfNullGiven() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $config = $processor->process($tb->buildTree(), [['transport' => null]]); + + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'null:', + ], + ], $config); + } + + public function testShouldSetNullTransportIfEmptyStringGiven() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $config = $processor->process($tb->buildTree(), [['transport' => '']]); + + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'null:', + ], + ], $config); + } + + public function testShouldSetNullTransportIfEmptyArrayGiven() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $config = $processor->process($tb->buildTree(), [['transport' => []]]); + + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'null:', + ], + ], $config); + } + + public function testThrowIfEmptyDsnGiven() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "foo.transport.dsn" cannot contain an empty value, but got "".'); + $processor->process($tb->buildTree(), [['transport' => ['dsn' => '']]]); + } + + public function testThrowIfFactoryClassAndFactoryServiceSetAtTheSameTime() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Both options factory_class and factory_service are set. Please choose one.'); + $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'factory_class' => 'aFactoryClass', + 'factory_service' => 'aFactoryService', + ], ]]); + } + + public function testThrowIfConnectionFactoryClassUsedWithFactoryClassAtTheSameTime() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + + $processor = new Processor(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The option connection_factory_class must not be used with factory_class or factory_service at the same time. Please choose one.'); + $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'connection_factory_class' => 'aFactoryClass', + 'factory_service' => 'aFactoryService', + ], ]]); + } + + public function testThrowIfConnectionFactoryClassUsedWithFactoryServiceAtTheSameTime() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + $processor = new Processor(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The option connection_factory_class must not be used with factory_class or factory_service at the same time. Please choose one.'); + $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'connection_factory_class' => 'aFactoryClass', + 'factory_service' => 'aFactoryService', + ], ]]); + } + + public function testShouldAllowSetFactoryClass() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + $processor = new Processor(); + + $config = $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'factory_class' => 'theFactoryClass', + ], ]]); + + $this->assertArrayHasKey('factory_class', $config['transport']); + $this->assertSame('theFactoryClass', $config['transport']['factory_class']); + } + + public function testShouldAllowSetFactoryService() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + $processor = new Processor(); + + $config = $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'factory_service' => 'theFactoryService', + ], ]]); + + $this->assertArrayHasKey('factory_service', $config['transport']); + $this->assertSame('theFactoryService', $config['transport']['factory_service']); + } + + public function testShouldAllowSetConnectionFactoryClass() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + $processor = new Processor(); + + $config = $processor->process($tb->buildTree(), [[ + 'transport' => [ + 'dsn' => 'foo:', + 'connection_factory_class' => 'theFactoryClass', + ], ]]); + + $this->assertArrayHasKey('connection_factory_class', $config['transport']); + $this->assertSame('theFactoryClass', $config['transport']['connection_factory_class']); + } + + public function testThrowIfExtraOptionGiven() + { + list($tb, $rootNode) = $this->getRootNode(); + + $rootNode->append(TransportFactory::getConfiguration()); + $processor = new Processor(); + + $config = $processor->process($tb->buildTree(), [['transport' => ['dsn' => 'foo:', 'extraOption' => 'aVal']]]); + $this->assertEquals([ + 'transport' => [ + 'dsn' => 'foo:', + 'extraOption' => 'aVal', + ], ], $config + ); + } + + public function testShouldBuildConnectionFactoryFromDSN() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $config = [ + 'dsn' => 'foo://bar/baz', + 'connection_factory_class' => null, + 'factory_service' => null, + 'factory_class' => null, + ]; + + $transport->buildConnectionFactory($container, $config); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory')); + + $this->assertNotEmpty($container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()); + $this->assertEquals( + [new Reference('enqueue.transport.default.connection_factory_factory'), 'create'], + $container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()) + ; + $this->assertSame( + [['dsn' => 'foo://bar/baz']], + $container->getDefinition('enqueue.transport.default.connection_factory')->getArguments()) + ; + } + + public function testShouldBuildConnectionFactoryUsingCustomFactoryClass() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $transport->buildConnectionFactory($container, ['dsn' => 'foo:', 'factory_class' => 'theFactoryClass']); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory_factory')); + $this->assertSame( + 'theFactoryClass', + $container->getDefinition('enqueue.transport.default.connection_factory_factory')->getClass() + ); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory')); + + $this->assertNotEmpty($container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()); + $this->assertEquals( + [new Reference('enqueue.transport.default.connection_factory_factory'), 'create'], + $container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()) + ; + $this->assertSame( + [['dsn' => 'foo:']], + $container->getDefinition('enqueue.transport.default.connection_factory')->getArguments()) + ; + } + + public function testShouldBuildConnectionFactoryUsingCustomFactoryService() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $transport->buildConnectionFactory($container, ['dsn' => 'foo:', 'factory_service' => 'theFactoryService']); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory')); + + $this->assertNotEmpty($container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()); + $this->assertEquals( + [new Reference('theFactoryService'), 'create'], + $container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()) + ; + $this->assertSame( + [['dsn' => 'foo:']], + $container->getDefinition('enqueue.transport.default.connection_factory')->getArguments()) + ; + } + + public function testShouldBuildConnectionFactoryUsingConnectionFactoryClassWithoutFactory() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $transport->buildConnectionFactory($container, ['dsn' => 'foo:', 'connection_factory_class' => 'theFactoryClass']); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.connection_factory')); + + $this->assertEmpty($container->getDefinition('enqueue.transport.default.connection_factory')->getFactory()); + $this->assertSame('theFactoryClass', $container->getDefinition('enqueue.transport.default.connection_factory')->getClass()); + $this->assertSame( + [['dsn' => 'foo:']], + $container->getDefinition('enqueue.transport.default.connection_factory')->getArguments()) + ; + } + + public function testShouldBuildContext() + { + $container = new ContainerBuilder(); + $container->register('enqueue.transport.default.connection_factory', ConnectionFactory::class); + + $transport = new TransportFactory('default'); + + $transport->buildContext($container, []); + + $this->assertNotEmpty($container->getDefinition('enqueue.transport.default.context')->getFactory()); + $this->assertEquals( + [new Reference('enqueue.transport.default.connection_factory'), 'createContext'], + $container->getDefinition('enqueue.transport.default.context')->getFactory()) + ; + $this->assertSame( + [], + $container->getDefinition('enqueue.transport.default.context')->getArguments()) + ; + } + + public function testThrowIfBuildContextCalledButConnectionFactoryServiceDoesNotExist() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The service "enqueue.transport.default.connection_factory" does not exist.'); + $transport->buildContext($container, []); + } + + public function testShouldBuildQueueConsumerWithDefaultOptions() + { + $container = new ContainerBuilder(); + $container->register('enqueue.transport.default.context', Context::class); + + $transport = new TransportFactory('default'); + + $transport->buildQueueConsumer($container, []); + + $this->assertSame(10000, $container->getParameter('enqueue.transport.default.receive_timeout')); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.consumption_extensions')); + $this->assertSame(ChainExtension::class, $container->getDefinition('enqueue.transport.default.consumption_extensions')->getClass()); + $this->assertSame([[]], $container->getDefinition('enqueue.transport.default.consumption_extensions')->getArguments()); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.queue_consumer')); + $this->assertSame(QueueConsumer::class, $container->getDefinition('enqueue.transport.default.queue_consumer')->getClass()); + $this->assertEquals([ + new Reference('enqueue.transport.default.context'), + new Reference('enqueue.transport.default.consumption_extensions'), + [], + new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE), + '%enqueue.transport.default.receive_timeout%', + ], $container->getDefinition('enqueue.transport.default.queue_consumer')->getArguments()); + } + + public function testShouldBuildQueueConsumerWithCustomOptions() + { + $container = new ContainerBuilder(); + $container->register('enqueue.transport.default.context', Context::class); + + $transport = new TransportFactory('default'); + + $transport->buildQueueConsumer($container, [ + 'receive_timeout' => 567, + ]); + + $this->assertSame(567, $container->getParameter('enqueue.transport.default.receive_timeout')); + } + + public function testThrowIfBuildQueueConsumerCalledButContextServiceDoesNotExist() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The service "enqueue.transport.default.context" does not exist.'); + $transport->buildQueueConsumer($container, []); + } + + public function testShouldBuildRpcClientWithDefaultOptions() + { + $container = new ContainerBuilder(); + $container->register('enqueue.transport.default.context', Context::class); + + $transport = new TransportFactory('default'); + + $transport->buildRpcClient($container, []); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.rpc_factory')); + $this->assertSame(RpcFactory::class, $container->getDefinition('enqueue.transport.default.rpc_factory')->getClass()); + + $this->assertTrue($container->hasDefinition('enqueue.transport.default.rpc_client')); + $this->assertSame(RpcClient::class, $container->getDefinition('enqueue.transport.default.rpc_client')->getClass()); + $this->assertEquals([ + new Reference('enqueue.transport.default.context'), + new Reference('enqueue.transport.default.rpc_factory'), + ], $container->getDefinition('enqueue.transport.default.rpc_client')->getArguments()); + } + + public function testThrowIfBuildRpcClientCalledButContextServiceDoesNotExist() + { + $container = new ContainerBuilder(); + + $transport = new TransportFactory('default'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The service "enqueue.transport.default.context" does not exist.'); + $transport->buildRpcClient($container, []); + } + + /** + * @return [TreeBuilder, NodeDefinition] + */ + private function getRootNode(): array + { + if (method_exists(TreeBuilder::class, 'getRootNode')) { + $tb = new TreeBuilder('foo'); + + return [$tb, $tb->getRootNode()]; + } + + $tb = new TreeBuilder(); + + return [$tb, $tb->root('foo')]; + } +} diff --git a/pkg/enqueue/Tests/Symfony/LazyProducerTest.php b/pkg/enqueue/Tests/Symfony/LazyProducerTest.php new file mode 100644 index 000000000..c8ba596a8 --- /dev/null +++ b/pkg/enqueue/Tests/Symfony/LazyProducerTest.php @@ -0,0 +1,128 @@ +assertClassImplements(ProducerInterface::class, LazyProducer::class); + } + + public function testShouldNotCallRealProducerInConstructor() + { + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->never()) + ->method('get') + ; + + new LazyProducer($containerMock, 'realProducerId'); + } + + public function testShouldProxyAllArgumentOnSendEvent() + { + $topic = 'theTopic'; + $message = 'theMessage'; + + $realProducerMock = $this->createProducerMock(); + $realProducerMock + ->expects($this->once()) + ->method('sendEvent') + ->with($topic, $message) + ; + + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('get') + ->with('realProducerId') + ->willReturn($realProducerMock) + ; + + $lazyProducer = new LazyProducer($containerMock, 'realProducerId'); + + $lazyProducer->sendEvent($topic, $message); + } + + public function testShouldProxyAllArgumentOnSendCommand() + { + $command = 'theCommand'; + $message = 'theMessage'; + $needReply = false; + + $realProducerMock = $this->createProducerMock(); + $realProducerMock + ->expects($this->once()) + ->method('sendCommand') + ->with($command, $message, $needReply) + ; + + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('get') + ->with('realProducerId') + ->willReturn($realProducerMock) + ; + + $lazyProducer = new LazyProducer($containerMock, 'realProducerId'); + + $result = $lazyProducer->sendCommand($command, $message, $needReply); + + $this->assertNull($result); + } + + public function testShouldProxyReturnedPromiseBackOnSendCommand() + { + $expectedPromise = $this->createMock(Promise::class); + + $realProducerMock = $this->createProducerMock(); + $realProducerMock + ->expects($this->once()) + ->method('sendCommand') + ->willReturn($expectedPromise) + ; + + $containerMock = $this->createContainerMock(); + $containerMock + ->expects($this->once()) + ->method('get') + ->with('realProducerId') + ->willReturn($realProducerMock) + ; + + $lazyProducer = new LazyProducer($containerMock, 'realProducerId'); + + $actualPromise = $lazyProducer->sendCommand('aCommand', 'aMessage', true); + + $this->assertSame($expectedPromise, $actualPromise); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerInterface + */ + private function createProducerMock(): ProducerInterface + { + return $this->createMock(ProducerInterface::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ContainerInterface + */ + private function createContainerMock(): ContainerInterface + { + return $this->createMock(ContainerInterface::class); + } +} diff --git a/pkg/enqueue/Tests/Symfony/NullTransportFactoryTest.php b/pkg/enqueue/Tests/Symfony/NullTransportFactoryTest.php deleted file mode 100644 index 6fda91891..000000000 --- a/pkg/enqueue/Tests/Symfony/NullTransportFactoryTest.php +++ /dev/null @@ -1,81 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, NullTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new NullTransportFactory(); - - $this->assertEquals('null', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new NullTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new NullTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), [true]); - - $this->assertEquals([], $config); - } - - public function testShouldCreateContext() - { - $container = new ContainerBuilder(); - - $transport = new NullTransportFactory(); - - $serviceId = $transport->createContext($container, []); - - $this->assertEquals('enqueue.transport.null.context', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $context = $container->getDefinition($serviceId); - $this->assertEquals(NullContext::class, $context->getClass()); - $this->assertNull($context->getFactory()); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new NullTransportFactory(); - - $driverId = $transport->createDriver($container, []); - - $this->assertEquals('enqueue.client.null.driver', $driverId); - $this->assertTrue($container->hasDefinition($driverId)); - - $context = $container->getDefinition($driverId); - $this->assertEquals(NullDriver::class, $context->getClass()); - $this->assertNull($context->getFactory()); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullConnectionFactoryTest.php b/pkg/enqueue/Tests/Transport/Null/NullConnectionFactoryTest.php deleted file mode 100644 index 6b9c549e4..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullConnectionFactoryTest.php +++ /dev/null @@ -1,32 +0,0 @@ -assertClassImplements(ConnectionFactory::class, NullConnectionFactory::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new NullConnectionFactory(); - } - - public function testShouldReturnNullContextOnCreateContextCall() - { - $factory = new NullConnectionFactory(); - - $context = $factory->createContext(); - - $this->assertInstanceOf(NullContext::class, $context); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullContextTest.php b/pkg/enqueue/Tests/Transport/Null/NullContextTest.php deleted file mode 100644 index 7e6425396..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullContextTest.php +++ /dev/null @@ -1,129 +0,0 @@ -assertClassImplements(Context::class, NullContext::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new NullContext(); - } - - public function testShouldAllowCreateMessageWithoutAnyArguments() - { - $context = new NullContext(); - - $message = $context->createMessage(); - - $this->assertInstanceOf(NullMessage::class, $message); - - $this->assertNull($message->getBody()); - $this->assertSame([], $message->getHeaders()); - $this->assertSame([], $message->getProperties()); - } - - public function testShouldAllowCreateCustomMessage() - { - $context = new NullContext(); - - $message = $context->createMessage('theBody', ['theProperty'], ['theHeader']); - - $this->assertInstanceOf(NullMessage::class, $message); - - $this->assertSame('theBody', $message->getBody()); - $this->assertSame(['theProperty'], $message->getProperties()); - $this->assertSame(['theHeader'], $message->getHeaders()); - } - - public function testShouldAllowCreateQueue() - { - $context = new NullContext(); - - $queue = $context->createQueue('aName'); - - $this->assertInstanceOf(NullQueue::class, $queue); - } - - public function testShouldAllowCreateTopic() - { - $context = new NullContext(); - - $topic = $context->createTopic('aName'); - - $this->assertInstanceOf(NullTopic::class, $topic); - } - - public function testShouldAllowCreateConsumerForGivenQueue() - { - $context = new NullContext(); - - $queue = new NullQueue('aName'); - - $consumer = $context->createConsumer($queue); - - $this->assertInstanceOf(NullConsumer::class, $consumer); - } - - public function testShouldAllowCreateProducer() - { - $context = new NullContext(); - - $producer = $context->createProducer(); - - $this->assertInstanceOf(NullProducer::class, $producer); - } - - public function testShouldDoNothingOnDeclareQueue() - { - $queue = new NullQueue('theQueueName'); - - $context = new NullContext(); - $context->declareQueue($queue); - } - - public function testShouldDoNothingOnDeclareTopic() - { - $topic = new NullTopic('theTopicName'); - - $context = new NullContext(); - $context->declareTopic($topic); - } - - public function testShouldDoNothingOnDeclareBind() - { - $topic = new NullTopic('theTopicName'); - $queue = new NullQueue('theQueueName'); - - $context = new NullContext(); - $context->declareBind($topic, $queue); - } - - public function testShouldCreateTempraryQueueWithUnqiueName() - { - $context = new NullContext(); - - $firstTmpQueue = $context->createTemporaryQueue(); - $secondTmpQueue = $context->createTemporaryQueue(); - - $this->assertInstanceOf(NullQueue::class, $firstTmpQueue); - $this->assertInstanceOf(NullQueue::class, $secondTmpQueue); - - $this->assertNotEquals($firstTmpQueue->getQueueName(), $secondTmpQueue->getQueueName()); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullMessageTest.php b/pkg/enqueue/Tests/Transport/Null/NullMessageTest.php deleted file mode 100644 index ddf9cd2d5..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullMessageTest.php +++ /dev/null @@ -1,203 +0,0 @@ -assertClassImplements(Message::class, NullMessage::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new NullMessage(); - } - - public function testShouldNewMessageReturnEmptyBody() - { - $message = new NullMessage(); - - $this->assertNull($message->getBody()); - } - - public function testShouldNewMessageReturnEmptyProperties() - { - $message = new NullMessage(); - - $this->assertSame([], $message->getProperties()); - } - - public function testShouldNewMessageReturnEmptyHeaders() - { - $message = new NullMessage(); - - $this->assertSame([], $message->getHeaders()); - } - - public function testShouldAllowGetPreviouslySetBody() - { - $message = new NullMessage(); - - $message->setBody('theBody'); - - $this->assertSame('theBody', $message->getBody()); - } - - public function testShouldAllowGetPreviouslySetHeaders() - { - $message = new NullMessage(); - - $message->setHeaders(['foo' => 'fooVal']); - - $this->assertSame(['foo' => 'fooVal'], $message->getHeaders()); - } - - public function testShouldAllowGetPreviouslySetProperties() - { - $message = new NullMessage(); - - $message->setProperties(['foo' => 'fooVal']); - - $this->assertSame(['foo' => 'fooVal'], $message->getProperties()); - } - - public function testShouldAllowGetByNamePreviouslySetProperty() - { - $message = new NullMessage(); - - $message->setProperties(['foo' => 'fooVal']); - - $this->assertSame('fooVal', $message->getProperty('foo')); - } - - public function testShouldAllowGetByNamePreviouslySetHeader() - { - $message = new NullMessage(); - - $message->setHeaders(['foo' => 'fooVal']); - - $this->assertSame('fooVal', $message->getHeader('foo')); - } - - public function testShouldReturnDefaultIfPropertyNotSet() - { - $message = new NullMessage(); - - $message->setProperties(['foo' => 'fooVal']); - - $this->assertSame('barDefault', $message->getProperty('bar', 'barDefault')); - } - - public function testShouldReturnDefaultIfHeaderNotSet() - { - $message = new NullMessage(); - - $message->setHeaders(['foo' => 'fooVal']); - - $this->assertSame('barDefault', $message->getHeader('bar', 'barDefault')); - } - - public function testShouldSetRedeliveredFalseInConstructor() - { - $message = new NullMessage(); - - $this->assertFalse($message->isRedelivered()); - } - - public function testShouldAllowGetPreviouslySetRedelivered() - { - $message = new NullMessage(); - $message->setRedelivered(true); - - $this->assertTrue($message->isRedelivered()); - } - - public function testShouldReturnEmptyStringAsDefaultCorrelationId() - { - $message = new NullMessage(); - - self::assertSame('', $message->getCorrelationId()); - } - - public function testShouldAllowGetPreviouslySetCorrelationId() - { - $message = new NullMessage(); - $message->setCorrelationId('theId'); - - self::assertSame('theId', $message->getCorrelationId()); - } - - public function testShouldCastCorrelationIdToStringOnSet() - { - $message = new NullMessage(); - $message->setCorrelationId(123); - - self::assertSame('123', $message->getCorrelationId()); - } - - public function testShouldReturnEmptyStringAsDefaultMessageId() - { - $message = new NullMessage(); - - self::assertSame('', $message->getMessageId()); - } - - public function testShouldAllowGetPreviouslySetMessageId() - { - $message = new NullMessage(); - $message->setMessageId('theId'); - - self::assertSame('theId', $message->getMessageId()); - } - - public function testShouldCastMessageIdToStringOnSet() - { - $message = new NullMessage(); - $message->setMessageId(123); - - self::assertSame('123', $message->getMessageId()); - } - - public function testShouldReturnNullAsDefaultTimestamp() - { - $message = new NullMessage(); - - self::assertSame(null, $message->getTimestamp()); - } - - public function testShouldAllowGetPreviouslySetTimestamp() - { - $message = new NullMessage(); - $message->setTimestamp(123); - - self::assertSame(123, $message->getTimestamp()); - } - - public function testShouldCastTimestampToIntOnSet() - { - $message = new NullMessage(); - $message->setTimestamp('123'); - - self::assertSame(123, $message->getTimestamp()); - } - - public function testShouldReturnNullAsDefaultReplyTo() - { - $message = new NullMessage(); - self::assertSame(null, $message->getReplyTo()); - } - - public function testShouldAllowGetPreviouslySetReplyTo() - { - $message = new NullMessage(); - $message->setReplyTo('theQueueName'); - self::assertSame('theQueueName', $message->getReplyTo()); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullProducerTest.php b/pkg/enqueue/Tests/Transport/Null/NullProducerTest.php deleted file mode 100644 index 1e90bf349..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullProducerTest.php +++ /dev/null @@ -1,31 +0,0 @@ -assertClassImplements(Producer::class, NullProducer::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new NullProducer(); - } - - public function testShouldDoNothingOnSend() - { - $producer = new NullProducer(); - - $producer->send(new NullTopic('aName'), new NullMessage()); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullQueueTest.php b/pkg/enqueue/Tests/Transport/Null/NullQueueTest.php deleted file mode 100644 index 989383b87..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullQueueTest.php +++ /dev/null @@ -1,29 +0,0 @@ -assertClassImplements(Queue::class, NullQueue::class); - } - - public function testCouldBeConstructedWithNameAsArgument() - { - new NullQueue('aName'); - } - - public function testShouldAllowGetNameSetInConstructor() - { - $queue = new NullQueue('theName'); - - $this->assertEquals('theName', $queue->getQueueName()); - } -} diff --git a/pkg/enqueue/Tests/Transport/Null/NullTopicTest.php b/pkg/enqueue/Tests/Transport/Null/NullTopicTest.php deleted file mode 100644 index 3a14cff0b..000000000 --- a/pkg/enqueue/Tests/Transport/Null/NullTopicTest.php +++ /dev/null @@ -1,29 +0,0 @@ -assertClassImplements(Topic::class, NullTopic::class); - } - - public function testCouldBeConstructedWithNameAsArgument() - { - new NullTopic('aName'); - } - - public function testShouldAllowGetNameSetInConstructor() - { - $topic = new NullTopic('theName'); - - $this->assertEquals('theName', $topic->getTopicName()); - } -} diff --git a/pkg/enqueue/Tests/Util/Fixtures/JsonSerializableClass.php b/pkg/enqueue/Tests/Util/Fixtures/JsonSerializableClass.php index b612978e7..1a77ce0cf 100644 --- a/pkg/enqueue/Tests/Util/Fixtures/JsonSerializableClass.php +++ b/pkg/enqueue/Tests/Util/Fixtures/JsonSerializableClass.php @@ -6,7 +6,8 @@ class JsonSerializableClass implements \JsonSerializable { public $keyPublic = 'public'; - public function jsonSerialize() + #[\ReturnTypeWillChange] + public function jsonSerialize(): array { return [ 'key' => 'value', diff --git a/pkg/enqueue/Tests/Util/JSONTest.php b/pkg/enqueue/Tests/Util/JSONTest.php index 97223b0d1..1a3df4211 100644 --- a/pkg/enqueue/Tests/Util/JSONTest.php +++ b/pkg/enqueue/Tests/Util/JSONTest.php @@ -5,8 +5,9 @@ use Enqueue\Tests\Util\Fixtures\JsonSerializableClass; use Enqueue\Tests\Util\Fixtures\SimpleClass; use Enqueue\Util\JSON; +use PHPUnit\Framework\TestCase; -class JSONTest extends \PHPUnit_Framework_TestCase +class JSONTest extends TestCase { public function testShouldDecodeString() { @@ -15,7 +16,8 @@ public function testShouldDecodeString() public function testThrowIfMalformedJson() { - $this->setExpectedException(\InvalidArgumentException::class, 'The malformed json given. '); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The malformed json given. '); $this->assertSame(['foo' => 'fooVal'], JSON::decode('{]')); } @@ -37,14 +39,11 @@ public function nonStringDataProvider() /** * @dataProvider nonStringDataProvider - * @param mixed $value */ public function testShouldThrowExceptionIfInputIsNotString($value) { - $this->setExpectedException( - \InvalidArgumentException::class, - 'Accept only string argument but got:' - ); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Accept only string argument but got:'); $this->assertSame(0, JSON::decode($value)); } @@ -94,10 +93,8 @@ public function testShouldEncodeObjectOfJsonSerializableClass() public function testThrowIfValueIsResource() { - $this->setExpectedException( - \InvalidArgumentException::class, - 'Could not encode value into json. Error 8 and message Type is not supported' - ); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Could not encode value into json. Error 8 and message Type is not supported'); $resource = fopen('php://memory', 'r'); fclose($resource); diff --git a/pkg/enqueue/Tests/Util/UUIDTest.php b/pkg/enqueue/Tests/Util/UUIDTest.php index 97028b361..f21693e78 100644 --- a/pkg/enqueue/Tests/Util/UUIDTest.php +++ b/pkg/enqueue/Tests/Util/UUIDTest.php @@ -3,14 +3,15 @@ namespace Enqueue\Tests\Util; use Enqueue\Util\UUID; +use PHPUnit\Framework\TestCase; -class UUIDTest extends \PHPUnit_Framework_TestCase +class UUIDTest extends TestCase { public function testShouldGenerateUniqueId() { $uuid = UUID::generate(); - $this->assertInternalType('string', $uuid); + $this->assertIsString($uuid); $this->assertEquals(36, strlen($uuid)); } diff --git a/pkg/enqueue/Tests/Util/VarExportTest.php b/pkg/enqueue/Tests/Util/VarExportTest.php index 0581a3024..b71e78a65 100644 --- a/pkg/enqueue/Tests/Util/VarExportTest.php +++ b/pkg/enqueue/Tests/Util/VarExportTest.php @@ -3,18 +3,12 @@ namespace Enqueue\Tests\Util; use Enqueue\Util\VarExport; +use PHPUnit\Framework\TestCase; -class VarExportTest extends \PHPUnit_Framework_TestCase +class VarExportTest extends TestCase { - public function testCouldBeConstructedWithValueAsArgument() - { - new VarExport('aVal'); - } - /** * @dataProvider provideValues - * @param mixed $value - * @param mixed $expected */ public function testShouldConvertValueToStringUsingVarExportFunction($value, $expected) { diff --git a/pkg/enqueue/Tests/fix_composer_json.php b/pkg/enqueue/Tests/fix_composer_json.php new file mode 100644 index 000000000..324f1840b --- /dev/null +++ b/pkg/enqueue/Tests/fix_composer_json.php @@ -0,0 +1,11 @@ +queue = $queue; - } - - /** - * {@inheritdoc} - */ - public function getQueue() - { - return $this->queue; - } - - /** - * {@inheritdoc} - */ - public function receive($timeout = 0) - { - return null; - } - - /** - * {@inheritdoc} - */ - public function receiveNoWait() - { - return null; - } - - /** - * {@inheritdoc} - */ - public function acknowledge(Message $message) - { - } - - /** - * {@inheritdoc} - */ - public function reject(Message $message, $requeue = false) - { - } -} diff --git a/pkg/enqueue/Transport/Null/NullContext.php b/pkg/enqueue/Transport/Null/NullContext.php deleted file mode 100644 index bdce45309..000000000 --- a/pkg/enqueue/Transport/Null/NullContext.php +++ /dev/null @@ -1,98 +0,0 @@ -setBody($body); - $message->setProperties($properties); - $message->setHeaders($headers); - - return $message; - } - - /** - * {@inheritdoc} - * - * @return NullQueue - */ - public function createQueue($name) - { - return new NullQueue($name); - } - - /** - * {@inheritdoc} - */ - public function createTemporaryQueue() - { - return $this->createQueue(uniqid('', true)); - } - - /** - * {@inheritdoc} - * - * @return NullTopic - */ - public function createTopic($name) - { - return new NullTopic($name); - } - - /** - * {@inheritdoc} - * - * @return NullConsumer - */ - public function createConsumer(Destination $destination) - { - return new NullConsumer($destination); - } - - /** - * {@inheritdoc} - */ - public function createProducer() - { - return new NullProducer(); - } - - /** - * {@inheritdoc} - */ - public function declareTopic(Destination $destination) - { - } - - /** - * {@inheritdoc} - */ - public function declareQueue(Destination $destination) - { - } - - /** - * {@inheritdoc} - */ - public function declareBind(Destination $source, Destination $target) - { - } - - /** - * {@inheritdoc} - */ - public function close() - { - } -} diff --git a/pkg/enqueue/Transport/Null/NullMessage.php b/pkg/enqueue/Transport/Null/NullMessage.php deleted file mode 100644 index 1dc12a395..000000000 --- a/pkg/enqueue/Transport/Null/NullMessage.php +++ /dev/null @@ -1,205 +0,0 @@ -properties = []; - $this->headers = []; - - $this->redelivered = false; - } - - /** - * {@inheritdoc} - */ - public function setBody($body) - { - $this->body = $body; - } - - /** - * {@inheritdoc} - */ - public function getBody() - { - return $this->body; - } - - /** - * {@inheritdoc} - */ - public function setProperties(array $properties) - { - $this->properties = $properties; - } - - /** - * {@inheritdoc} - */ - public function getProperties() - { - return $this->properties; - } - - /** - * {@inheritdoc} - */ - public function setProperty($name, $value) - { - $this->properties[$name] = $value; - } - - /** - * {@inheritdoc} - */ - public function getProperty($name, $default = null) - { - return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; - } - - /** - * {@inheritdoc} - */ - public function setHeaders(array $headers) - { - $this->headers = $headers; - } - - /** - * {@inheritdoc} - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * {@inheritdoc} - */ - public function setHeader($name, $value) - { - $this->headers[$name] = $value; - } - - /** - * {@inheritdoc} - */ - public function getHeader($name, $default = null) - { - return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; - } - - /** - * {@inheritdoc} - */ - public function isRedelivered() - { - return $this->redelivered; - } - - /** - * {@inheritdoc} - */ - public function setRedelivered($redelivered) - { - $this->redelivered = $redelivered; - } - - /** - * {@inheritdoc} - */ - public function setCorrelationId($correlationId) - { - $headers = $this->getHeaders(); - $headers['correlation_id'] = (string) $correlationId; - - $this->setHeaders($headers); - } - - /** - * {@inheritdoc} - */ - public function getCorrelationId() - { - return $this->getHeader('correlation_id', ''); - } - - /** - * {@inheritdoc} - */ - public function setMessageId($messageId) - { - $headers = $this->getHeaders(); - $headers['message_id'] = (string) $messageId; - - $this->setHeaders($headers); - } - - /** - * {@inheritdoc} - */ - public function getMessageId() - { - return $this->getHeader('message_id', ''); - } - - /** - * {@inheritdoc} - */ - public function getTimestamp() - { - return $this->getHeader('timestamp'); - } - - /** - * {@inheritdoc} - */ - public function setTimestamp($timestamp) - { - $headers = $this->getHeaders(); - $headers['timestamp'] = (int) $timestamp; - - $this->setHeaders($headers); - } - - /** - * @param string|null $replyTo - */ - public function setReplyTo($replyTo) - { - $this->setHeader('reply_to', $replyTo); - } - - /** - * @return string|null - */ - public function getReplyTo() - { - return $this->getHeader('reply_to'); - } -} diff --git a/pkg/enqueue/Transport/Null/NullProducer.php b/pkg/enqueue/Transport/Null/NullProducer.php deleted file mode 100644 index b60bc410c..000000000 --- a/pkg/enqueue/Transport/Null/NullProducer.php +++ /dev/null @@ -1,17 +0,0 @@ -name = $name; - } - - /** - * @return string - */ - public function getQueueName() - { - return $this->name; - } -} diff --git a/pkg/enqueue/Transport/Null/NullTopic.php b/pkg/enqueue/Transport/Null/NullTopic.php deleted file mode 100644 index 313d600b5..000000000 --- a/pkg/enqueue/Transport/Null/NullTopic.php +++ /dev/null @@ -1,29 +0,0 @@ -name = $name; - } - - /** - * @return string - */ - public function getTopicName() - { - return $this->name; - } -} diff --git a/pkg/enqueue/Util/JSON.php b/pkg/enqueue/Util/JSON.php index f85738e1d..67411af16 100644 --- a/pkg/enqueue/Util/JSON.php +++ b/pkg/enqueue/Util/JSON.php @@ -14,10 +14,7 @@ class JSON public static function decode($string) { if (!is_string($string)) { - throw new \InvalidArgumentException(sprintf( - 'Accept only string argument but got: "%s"', - is_object($string) ? get_class($string) : gettype($string) - )); + throw new \InvalidArgumentException(sprintf('Accept only string argument but got: "%s"', is_object($string) ? $string::class : gettype($string))); } // PHP7 fix - empty string and null cause syntax error @@ -26,32 +23,22 @@ public static function decode($string) } $decoded = json_decode($string, true); - if (JSON_ERROR_NONE !== json_last_error()) { - throw new \InvalidArgumentException(sprintf( - 'The malformed json given. Error %s and message %s', - json_last_error(), - json_last_error_msg() - )); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); } return $decoded; } /** - * @param mixed $value - * * @return string */ public static function encode($value) { - $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); - - if (JSON_ERROR_NONE !== json_last_error()) { - throw new \InvalidArgumentException(sprintf( - 'Could not encode value into json. Error %s and message %s', - json_last_error(), - json_last_error_msg() - )); + $encoded = json_encode($value, \JSON_UNESCAPED_UNICODE); + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('Could not encode value into json. Error %s and message %s', json_last_error(), json_last_error_msg())); } return $encoded; diff --git a/pkg/enqueue/Util/Stringify.php b/pkg/enqueue/Util/Stringify.php new file mode 100644 index 000000000..d8a48a8d6 --- /dev/null +++ b/pkg/enqueue/Util/Stringify.php @@ -0,0 +1,30 @@ +value = $value; + } + + public function __toString(): string + { + if (is_string($this->value) || is_scalar($this->value)) { + return $this->value; + } + + return json_encode($this->value, \JSON_UNESCAPED_SLASHES); + } + + public static function that($value): self + { + return new self($value); + } +} diff --git a/pkg/enqueue/Util/VarExport.php b/pkg/enqueue/Util/VarExport.php index 4a48afadd..9a914706d 100644 --- a/pkg/enqueue/Util/VarExport.php +++ b/pkg/enqueue/Util/VarExport.php @@ -7,14 +7,8 @@ */ class VarExport { - /** - * @var mixed - */ private $value; - /** - * @param mixed $value - */ public function __construct($value) { $this->value = $value; diff --git a/pkg/enqueue/composer.json b/pkg/enqueue/composer.json index 684e2b185..c336c4bad 100644 --- a/pkg/enqueue/composer.json +++ b/pkg/enqueue/composer.json @@ -3,29 +3,61 @@ "type": "library", "description": "Message Queue Library", "keywords": ["messaging", "queue", "amqp", "rabbitmq"], + "homepage": "https://enqueue.forma-pro.com/", "license": "MIT", - "repositories": [ - { - "type": "vcs", - "url": "git@github.com:php-enqueue/test.git" - } - ], "require": { - "php": ">=5.6", - "enqueue/psr-queue": "^0.2", - "ramsey/uuid": "^2|^3.5" + "php": "^8.1", + "queue-interop/amqp-interop": "^0.8.2", + "queue-interop/queue-interop": "^0.8", + "enqueue/null": "^0.10", + "enqueue/dsn": "^0.10", + "ramsey/uuid": "^3.5|^4", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.1 || ^2.0" }, "require-dev": { - "phpunit/phpunit": "~5.5", - "symfony/console": "^2.8|^3", - "symfony/dependency-injection": "^2.8|^3", - "symfony/config": "^2.8|^3", - "enqueue/test": "^0.2" + "phpunit/phpunit": "^9.5", + "symfony/console": "^5.41|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0", + "enqueue/amqp-ext": "0.10.x-dev", + "enqueue/amqp-lib": "0.10.x-dev", + "enqueue/amqp-bunny": "0.10.x-dev", + "enqueue/pheanstalk": "0.10.x-dev", + "enqueue/gearman": "0.10.x-dev", + "enqueue/rdkafka": "0.10.x-dev", + "enqueue/dbal": "0.10.x-dev", + "enqueue/fs": "0.10.x-dev", + "enqueue/gps": "0.10.x-dev", + "enqueue/redis": "0.10.x-dev", + "enqueue/sqs": "0.10.x-dev", + "enqueue/stomp": "0.10.x-dev", + "enqueue/test": "0.10.x-dev", + "enqueue/simple-client": "0.10.x-dev", + "enqueue/mongodb": "0.10.x-dev", + "empi89/php-amqp-stubs": "*@dev", + "enqueue/dsn": "0.10.x-dev" }, "suggest": { - "symfony/console": "^2.8|^3 If you want to use li commands", - "symfony/dependency-injection": "^2.8|^3", - "symfony/config": "^2.8|^3" + "symfony/console": "^5.4|^6.0 If you want to use cli commands", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "enqueue/amqp-ext": "AMQP transport (based on php extension)", + "enqueue/stomp": "STOMP transport", + "enqueue/fs": "Filesystem transport", + "enqueue/redis": "Redis transport", + "enqueue/dbal": "Doctrine DBAL transport", + "enqueue/sqs": "Amazon AWS SQS transport" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" }, "autoload": { "psr-4": { "Enqueue\\": "" }, @@ -33,10 +65,15 @@ "/Tests/" ] }, + "config": { + "platform": { + "ext-amqp": "1.9.3" + } + }, "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } } } diff --git a/pkg/enqueue/phpunit.xml.dist b/pkg/enqueue/phpunit.xml.dist index 156ab4929..69c12ca1e 100644 --- a/pkg/enqueue/phpunit.xml.dist +++ b/pkg/enqueue/phpunit.xml.dist @@ -1,16 +1,11 @@ - + diff --git a/pkg/fs/.gitattributes b/pkg/fs/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/fs/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/fs/.github/workflows/ci.yml b/pkg/fs/.github/workflows/ci.yml new file mode 100644 index 000000000..65cfbbb2d --- /dev/null +++ b/pkg/fs/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: SYMFONY_DEPRECATIONS_HELPER=weak vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/fs/.gitignore b/pkg/fs/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/fs/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/fs/CannotObtainLockException.php b/pkg/fs/CannotObtainLockException.php new file mode 100644 index 000000000..2788dede8 --- /dev/null +++ b/pkg/fs/CannotObtainLockException.php @@ -0,0 +1,11 @@ + 'the directory where all queue\topic files remain. For example /home/foo/enqueue', + * 'pre_fetch_count' => 'Integer. Defines how many messages to fetch from the file.', + * 'chmod' => 'Defines a mode the files are created with', + * 'polling_interval' => 'How often query for new messages, default 100 (milliseconds)', + * ] + * + * or + * + * file: - create queue files in tmp dir. + * file:///home/foo/enqueue + * file:///home/foo/enqueue?pre_fetch_count=20&chmod=0777 + * + * @param array|string|null $config + */ + public function __construct($config = 'file:') + { + if (empty($config) || 'file:' === $config) { + $config = $this->parseDsn('file://'.sys_get_temp_dir().'/enqueue'); + } elseif (is_string($config)) { + if ('/' === $config[0]) { + $config = $this->parseDsn('file://'.$config); + } else { + $config = $this->parseDsn($config); + } + } elseif (is_array($config)) { + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace($this->defaultConfig(), $config); + + if (empty($this->config['path'])) { + throw new \LogicException('The path option must be set.'); + } + } + + /** + * @return FsContext + */ + public function createContext(): Context + { + return new FsContext( + $this->config['path'], + $this->config['pre_fetch_count'], + $this->config['chmod'], + $this->config['polling_interval'] + ); + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + $supportedSchemes = ['file']; + if (false == in_array($dsn->getSchemeProtocol(), $supportedSchemes, true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be one of "%s"', $dsn->getSchemeProtocol(), implode('", "', $supportedSchemes))); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'path' => $dsn->getPath(), + 'pre_fetch_count' => $dsn->getDecimal('pre_fetch_count'), + 'chmod' => $dsn->getOctal('chmod'), + 'polling_interval' => $dsn->getDecimal('polling_interval'), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'path' => null, + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ]; + } +} diff --git a/pkg/fs/FsConsumer.php b/pkg/fs/FsConsumer.php new file mode 100644 index 000000000..614461eb2 --- /dev/null +++ b/pkg/fs/FsConsumer.php @@ -0,0 +1,214 @@ +context = $context; + $this->destination = $destination; + $this->preFetchCount = $preFetchCount; + + $this->preFetchedMessages = []; + } + + /** + * Set polling interval in milliseconds. + */ + public function setPollingInterval(int $msec): void + { + $this->pollingInterval = $msec; + } + + /** + * Get polling interval in milliseconds. + */ + public function getPollingInterval(): int + { + return $this->pollingInterval; + } + + /** + * @return FsDestination + */ + public function getQueue(): Queue + { + return $this->destination; + } + + /** + * @return FsMessage + */ + public function receive(int $timeout = 0): ?Message + { + $timeout /= 1000; + $startAt = microtime(true); + + while (true) { + $message = $this->receiveNoWait(); + + if ($message) { + return $message; + } + + if ($timeout && (microtime(true) - $startAt) >= $timeout) { + return null; + } + + usleep($this->pollingInterval * 1000); + + if ($timeout && (microtime(true) - $startAt) >= $timeout) { + return null; + } + } + } + + /** + * @return FsMessage + */ + public function receiveNoWait(): ?Message + { + if ($this->preFetchedMessages) { + return array_shift($this->preFetchedMessages); + } + + $this->context->workWithFile($this->destination, 'c+', function (FsDestination $destination, $file) { + $count = $this->preFetchCount; + while ($count) { + $frame = $this->readFrame($file, 1); + + // guards + if ($frame && false == ('|' == $frame[0] || ' ' == $frame[0])) { + throw new \LogicException(sprintf('The frame could start from either " " or "|". The malformed frame starts with "%s".', $frame[0])); + } + if (0 !== $reminder = strlen($frame) % 64) { + throw new \LogicException(sprintf('The frame size is "%d" and it must divide exactly to 64 but it leaves a reminder "%d".', strlen($frame), $reminder)); + } + + ftruncate($file, fstat($file)['size'] - strlen($frame)); + rewind($file); + + $rawMessage = str_replace('\|\{', '|{', $frame); + $rawMessage = substr(trim($rawMessage), 1); + + if ($rawMessage) { + try { + $fetchedMessage = FsMessage::jsonUnserialize($rawMessage); + $expireAt = $fetchedMessage->getHeader('x-expire-at'); + if ($expireAt && $expireAt - microtime(true) < 0) { + // message has expired, just drop it. + return null; + } + + $this->preFetchedMessages[] = $fetchedMessage; + } catch (\Exception $e) { + throw new \LogicException(sprintf("Cannot decode json message '%s'", $rawMessage), 0, $e); + } + } else { + return null; + } + + --$count; + } + }); + + if ($this->preFetchedMessages) { + return array_shift($this->preFetchedMessages); + } + + return null; + } + + public function acknowledge(Message $message): void + { + // do nothing. fs transport always works in auto ack mode + } + + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, FsMessage::class); + + // do nothing on reject. fs transport always works in auto ack mode + + if ($requeue) { + $this->context->createProducer()->send($this->destination, $message); + } + } + + public function getPreFetchCount(): int + { + return $this->preFetchCount; + } + + public function setPreFetchCount(int $preFetchCount): void + { + $this->preFetchCount = $preFetchCount; + } + + /** + * @param resource $file + */ + private function readFrame($file, int $frameNumber): string + { + $frameSize = 64; + $offset = $frameNumber * $frameSize; + + fseek($file, -$offset, \SEEK_END); + $frame = fread($file, $frameSize); + if ('' == $frame) { + return ''; + } + + if (str_contains($frame, '|{')) { + return $frame; + } + + $previousFrame = $this->readFrame($file, $frameNumber + 1); + + if ('|' === substr($previousFrame, -1) && '{' === $frame[0]) { + $matched = []; + if (false === preg_match('/\ *?\|$/', $previousFrame, $matched)) { + throw new \LogicException('Something went completely wrong.'); + } + + return $matched[0].$frame; + } + + return $previousFrame.$frame; + } +} diff --git a/pkg/fs/FsContext.php b/pkg/fs/FsContext.php new file mode 100644 index 000000000..c735e13aa --- /dev/null +++ b/pkg/fs/FsContext.php @@ -0,0 +1,205 @@ +mkdir($storeDir); + + $this->storeDir = $storeDir; + $this->preFetchCount = $preFetchCount; + $this->chmod = $chmod; + $this->pollingInterval = $pollingInterval; + + $this->lock = new LegacyFilesystemLock(); + } + + /** + * @return FsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new FsMessage($body, $properties, $headers); + } + + /** + * @return FsDestination + */ + public function createTopic(string $topicName): Topic + { + return $this->createQueue($topicName); + } + + /** + * @return FsDestination + */ + public function createQueue(string $queueName): Queue + { + return new FsDestination(new \SplFileInfo($this->getStoreDir().'/'.$queueName)); + } + + public function declareDestination(FsDestination $destination): void + { + // InvalidDestinationException::assertDestinationInstanceOf($destination, FsDestination::class); + + set_error_handler(function ($severity, $message, $file, $line) { + throw new \ErrorException($message, 0, $severity, $file, $line); + }); + + try { + if (false == file_exists((string) $destination->getFileInfo())) { + touch((string) $destination->getFileInfo()); + chmod((string) $destination->getFileInfo(), $this->chmod); + } + } finally { + restore_error_handler(); + } + } + + public function workWithFile(FsDestination $destination, string $mode, callable $callback) + { + $this->declareDestination($destination); + + set_error_handler(function ($severity, $message, $file, $line) { + throw new \ErrorException($message, 0, $severity, $file, $line); + }, \E_ALL & ~\E_USER_DEPRECATED); + + try { + $file = fopen((string) $destination->getFileInfo(), $mode); + $this->lock->lock($destination); + + return call_user_func($callback, $destination, $file); + } finally { + if (isset($file)) { + fclose($file); + } + $this->lock->release($destination); + + restore_error_handler(); + } + } + + /** + * @return FsDestination + */ + public function createTemporaryQueue(): Queue + { + return new FsDestination( + new TempFile($this->getStoreDir().'/'.uniqid('tmp-q-', true)) + ); + } + + /** + * @return FsProducer + */ + public function createProducer(): Producer + { + return new FsProducer($this); + } + + /** + * @param FsDestination $destination + * + * @return FsConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, FsDestination::class); + + $consumer = new FsConsumer($this, $destination, $this->preFetchCount); + + if ($this->pollingInterval) { + $consumer->setPollingInterval($this->pollingInterval); + } + + return $consumer; + } + + public function close(): void + { + $this->lock->releaseAll(); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @param FsDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, FsDestination::class); + + $this->workWithFile($queue, 'c', function (FsDestination $destination, $file) { + ftruncate($file, 0); + }); + } + + public function getPreFetchCount(): int + { + return $this->preFetchCount; + } + + public function setPreFetchCount(int $preFetchCount): void + { + $this->preFetchCount = $preFetchCount; + } + + private function getStoreDir(): string + { + if (false == is_dir($this->storeDir)) { + throw new \LogicException(sprintf('The directory %s does not exist', $this->storeDir)); + } + + if (false == is_writable($this->storeDir)) { + throw new \LogicException(sprintf('The directory %s is not writable', $this->storeDir)); + } + + return $this->storeDir; + } +} diff --git a/pkg/fs/FsDestination.php b/pkg/fs/FsDestination.php new file mode 100644 index 000000000..559391785 --- /dev/null +++ b/pkg/fs/FsDestination.php @@ -0,0 +1,41 @@ +file = $file; + } + + public function getFileInfo(): \SplFileInfo + { + return $this->file; + } + + public function getName(): string + { + return $this->file->getFilename(); + } + + public function getQueueName(): string + { + return $this->file->getFilename(); + } + + public function getTopicName(): string + { + return $this->file->getFilename(); + } +} diff --git a/pkg/fs/FsMessage.php b/pkg/fs/FsMessage.php new file mode 100644 index 000000000..45312e52c --- /dev/null +++ b/pkg/fs/FsMessage.php @@ -0,0 +1,159 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', (string) $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', (string) $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function jsonSerialize(): array + { + return [ + 'body' => $this->getBody(), + 'properties' => $this->getProperties(), + 'headers' => $this->getHeaders(), + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['body'], $data['properties'], $data['headers']); + } +} diff --git a/pkg/fs/FsProducer.php b/pkg/fs/FsProducer.php new file mode 100644 index 000000000..067e54b36 --- /dev/null +++ b/pkg/fs/FsProducer.php @@ -0,0 +1,105 @@ +context = $context; + } + + /** + * @param FsDestination $destination + * @param FsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, FsDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, FsMessage::class); + + $this->context->workWithFile($destination, 'a+', function (FsDestination $destination, $file) use ($message) { + $fileInfo = $destination->getFileInfo(); + if ($fileInfo instanceof TempFile && false == file_exists((string) $fileInfo)) { + return; + } + + if (null !== $this->timeToLive) { + $message->setHeader('x-expire-at', microtime(true) + ($this->timeToLive / 1000)); + } + + $rawMessage = json_encode($message); + $rawMessage = str_replace('|{', '\|\{', $rawMessage); + $rawMessage = '|'.$rawMessage; + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('Could not encode value into json. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + $rawMessage = str_repeat(' ', 64 - (strlen($rawMessage) % 64)).$rawMessage; + + fwrite($file, $rawMessage); + }); + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/fs/LICENSE b/pkg/fs/LICENSE new file mode 100644 index 000000000..70fa75252 --- /dev/null +++ b/pkg/fs/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/pkg/fs/LegacyFilesystemLock.php b/pkg/fs/LegacyFilesystemLock.php new file mode 100644 index 000000000..328fb7098 --- /dev/null +++ b/pkg/fs/LegacyFilesystemLock.php @@ -0,0 +1,177 @@ +lockHandlers = []; + } + + public function lock(FsDestination $destination) + { + $lockHandler = $this->getLockHandler($destination); + + if (false == $lockHandler->lock(true)) { + throw new CannotObtainLockException(sprintf('Cannot obtain the lock for destination %s', $destination->getName())); + } + } + + public function release(FsDestination $destination) + { + $lockHandler = $this->getLockHandler($destination); + + $lockHandler->release(); + } + + public function releaseAll() + { + foreach ($this->lockHandlers as $lockHandler) { + $lockHandler->release(); + } + + $this->lockHandlers = []; + } + + /** + * @return LockHandler + */ + private function getLockHandler(FsDestination $destination) + { + if (false == isset($this->lockHandlers[$destination->getName()])) { + $this->lockHandlers[$destination->getName()] = new LockHandler( + $destination->getName(), + $destination->getFileInfo()->getPath() + ); + } + + return $this->lockHandlers[$destination->getName()]; + } +} + +// symfony/lock component works only with 3.x and 4.x Symfony +// For symfony 2.x we should use LockHandler from symfony/component which was removed from 4.x +// because we cannot use both at the same time. I copied and pasted the lock handler here + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * LockHandler class provides a simple abstraction to lock anything by means of + * a file lock. + * + * A locked file is created based on the lock name when calling lock(). Other + * lock handlers will not be able to lock the same name until it is released + * (explicitly by calling release() or implicitly when the instance holding the + * lock is destroyed). + * + * @author Grégoire Pineau + * @author Romain Neutron + * @author Nicolas Grekas + * + * @deprecated since version 3.4, to be removed in 4.0. Use Symfony\Component\Lock\Store\SemaphoreStore or Symfony\Component\Lock\Store\FlockStore instead. + */ +class LockHandler +{ + private $file; + private $handle; + + /** + * @param string $name The lock name + * @param string|null $lockPath The directory to store the lock. Default values will use temporary directory + * + * @throws IOException If the lock directory could not be created or is not writable + */ + public function __construct($name, $lockPath = null) + { + $lockPath = $lockPath ?: sys_get_temp_dir(); + + if (!is_dir($lockPath)) { + $fs = new Filesystem(); + $fs->mkdir($lockPath); + } + + if (!is_writable($lockPath)) { + throw new IOException(sprintf('The directory "%s" is not writable.', $lockPath), 0, null, $lockPath); + } + + $this->file = sprintf('%s/sf.%s.%s.lock', $lockPath, preg_replace('/[^a-z0-9\._-]+/i', '-', $name), hash('sha256', $name)); + } + + /** + * Lock the resource. + * + * @param bool $blocking Wait until the lock is released + * + * @throws IOException If the lock file could not be created or opened + * + * @return bool Returns true if the lock was acquired, false otherwise + */ + public function lock($blocking = false) + { + if ($this->handle) { + return true; + } + + $error = null; + + // Silence error reporting + set_error_handler(function ($errno, $msg) use (&$error) { + $error = $msg; + }); + + if (!$this->handle = fopen($this->file, 'r')) { + if ($this->handle = fopen($this->file, 'x')) { + chmod($this->file, 0444); + } elseif (!$this->handle = fopen($this->file, 'r')) { + usleep(100); // Give some time for chmod() to complete + $this->handle = fopen($this->file, 'r'); + } + } + restore_error_handler(); + + if (!$this->handle) { + throw new IOException($error, 0, null, $this->file); + } + + // On Windows, even if PHP doc says the contrary, LOCK_NB works, see + // https://bugs.php.net/54129 + if (!flock($this->handle, \LOCK_EX | ($blocking ? 0 : \LOCK_NB))) { + fclose($this->handle); + $this->handle = null; + + return false; + } + + return true; + } + + /** + * Release the resource. + */ + public function release() + { + if ($this->handle) { + flock($this->handle, \LOCK_UN | \LOCK_NB); + fclose($this->handle); + $this->handle = null; + } + } +} diff --git a/pkg/fs/Lock.php b/pkg/fs/Lock.php new file mode 100644 index 000000000..16349f22c --- /dev/null +++ b/pkg/fs/Lock.php @@ -0,0 +1,20 @@ +Supporting Enqueue + +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Enqueue Filesystem Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/fs/ci.yml?branch=master)](https://github.com/php-enqueue/fs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/fs/d/total.png)](https://packagist.org/packages/enqueue/fs) +[![Latest Stable Version](https://poser.pugx.org/enqueue/fs/version.png)](https://packagist.org/packages/enqueue/fs) + +This is an implementation of Queue Interop specification. It allows you to send and consume message stored locally in files. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/filesystem/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/fs/Tests/FsConnectionFactoryConfigTest.php b/pkg/fs/Tests/FsConnectionFactoryConfigTest.php new file mode 100644 index 000000000..0b3411f2c --- /dev/null +++ b/pkg/fs/Tests/FsConnectionFactoryConfigTest.php @@ -0,0 +1,134 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new FsConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotFileScheme() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be one of "file"'); + + new FsConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new FsConnectionFactory('foo'); + } + + public function testThrowIfArrayConfigGivenWithEmptyPath() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The path option must be set.'); + + new FsConnectionFactory([ + 'path' => null, + ]); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new FsConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'path' => sys_get_temp_dir().'/enqueue', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + '', + [ + 'path' => sys_get_temp_dir().'/enqueue', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + [], + [ + 'path' => sys_get_temp_dir().'/enqueue', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + 'file:', + [ + 'path' => sys_get_temp_dir().'/enqueue', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + '/foo/bar/baz', + [ + 'path' => '/foo/bar/baz', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + 'file:///foo/bar/baz', + [ + 'path' => '/foo/bar/baz', + 'pre_fetch_count' => 1, + 'chmod' => 0600, + 'polling_interval' => 100, + ], + ]; + + yield [ + 'file:///foo/bar/baz?pre_fetch_count=100&chmod=0666', + [ + 'path' => '/foo/bar/baz', + 'pre_fetch_count' => 100, + 'chmod' => 0666, + 'polling_interval' => 100, + ], + ]; + } +} diff --git a/pkg/fs/Tests/FsConnectionFactoryTest.php b/pkg/fs/Tests/FsConnectionFactoryTest.php new file mode 100644 index 000000000..2df442342 --- /dev/null +++ b/pkg/fs/Tests/FsConnectionFactoryTest.php @@ -0,0 +1,37 @@ +assertClassImplements(ConnectionFactory::class, FsConnectionFactory::class); + } + + public function testShouldCreateContext() + { + $factory = new FsConnectionFactory([ + 'path' => __DIR__, + 'pre_fetch_count' => 123, + 'chmod' => 0765, + ]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(FsContext::class, $context); + + $this->assertAttributeSame(__DIR__, 'storeDir', $context); + $this->assertAttributeSame(123, 'preFetchCount', $context); + $this->assertAttributeSame(0765, 'chmod', $context); + } +} diff --git a/pkg/fs/Tests/FsConsumerTest.php b/pkg/fs/Tests/FsConsumerTest.php new file mode 100644 index 000000000..67f03ae98 --- /dev/null +++ b/pkg/fs/Tests/FsConsumerTest.php @@ -0,0 +1,152 @@ +assertClassImplements(Consumer::class, FsConsumer::class); + } + + public function testShouldReturnDestinationSetInConstructorOnGetQueue() + { + $destination = new FsDestination(TempFile::generate()); + + $consumer = new FsConsumer($this->createContextMock(), $destination, 1); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testShouldAllowGetPreFetchCountSetInConstructor() + { + $consumer = new FsConsumer($this->createContextMock(), new FsDestination(TempFile::generate()), 123); + + $this->assertSame(123, $consumer->getPreFetchCount()); + } + + public function testShouldAllowGetPreviouslySetPreFetchCount() + { + $consumer = new FsConsumer($this->createContextMock(), new FsDestination(TempFile::generate()), 123); + + $consumer->setPreFetchCount(456); + + $this->assertSame(456, $consumer->getPreFetchCount()); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldDoNothingOnAcknowledge() + { + $consumer = new FsConsumer($this->createContextMock(), new FsDestination(TempFile::generate()), 123); + + $consumer->acknowledge(new FsMessage()); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldDoNothingOnReject() + { + $consumer = new FsConsumer($this->createContextMock(), new FsDestination(TempFile::generate()), 123); + + $consumer->reject(new FsMessage()); + } + + public function testCouldSetAndGetPollingInterval() + { + $consumer = new FsConsumer($this->createContextMock(), new FsDestination(TempFile::generate()), 123); + $consumer->setPollingInterval(123456); + + $this->assertEquals(123456, $consumer->getPollingInterval()); + } + + public function testShouldSendSameMessageToDestinationOnReQueue() + { + $message = new FsMessage(); + + $destination = new FsDestination(TempFile::generate()); + + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($destination), $this->identicalTo($message)) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producerMock) + ; + + $consumer = new FsConsumer($contextMock, $destination, 123); + + $consumer->reject($message, true); + } + + public function testShouldCallContextWorkWithFileAndCallbackToItOnReceiveNoWait() + { + $destination = new FsDestination(TempFile::generate()); + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->once()) + ->method('workWithFile') + ->with($this->identicalTo($destination), 'c+', $this->isInstanceOf(\Closure::class)) + ; + + $consumer = new FsConsumer($contextMock, $destination, 1); + + $consumer->receiveNoWait(); + } + + public function testShouldWaitTwoSecondsForMessageAndExitOnReceive() + { + $destination = new FsDestination(TempFile::generate()); + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->atLeastOnce()) + ->method('workWithFile') + ; + + $consumer = new FsConsumer($contextMock, $destination, 1); + + $start = microtime(true); + $consumer->receive(2000); + $end = microtime(true); + + $this->assertGreaterThan(1.5, $end - $start); + $this->assertLessThan(3.5, $end - $start); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|FsProducer + */ + private function createProducerMock() + { + return $this->createMock(FsProducer::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|FsContext + */ + private function createContextMock() + { + return $this->createMock(FsContext::class); + } +} diff --git a/pkg/fs/Tests/FsContextTest.php b/pkg/fs/Tests/FsContextTest.php new file mode 100644 index 000000000..9d5a5f1fc --- /dev/null +++ b/pkg/fs/Tests/FsContextTest.php @@ -0,0 +1,222 @@ +assertClassImplements(Context::class, FsContext::class); + } + + public function testShouldAllowCreateEmptyMessage() + { + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $message = $context->createMessage(); + + $this->assertInstanceOf(FsMessage::class, $message); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testShouldAllowCreateCustomMessage() + { + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $message = $context->createMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $this->assertInstanceOf(FsMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testShouldCreateQueue() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $this->assertInstanceOf(FsDestination::class, $queue); + $this->assertInstanceOf(\SplFileInfo::class, $queue->getFileInfo()); + $this->assertSame((string) $tmpFile, (string) $queue->getFileInfo()); + + $this->assertSame($tmpFile->getFilename(), $queue->getTopicName()); + } + + public function testShouldAllowCreateTopic() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $topic = $context->createTopic($tmpFile->getFilename()); + + $this->assertInstanceOf(FsDestination::class, $topic); + $this->assertInstanceOf(\SplFileInfo::class, $topic->getFileInfo()); + $this->assertSame((string) $tmpFile, (string) $topic->getFileInfo()); + + $this->assertSame($tmpFile->getFilename(), $topic->getTopicName()); + } + + public function testShouldAllowCreateTmpQueue() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $queue = $context->createTemporaryQueue(); + + $this->assertInstanceOf(FsDestination::class, $queue); + $this->assertInstanceOf(TempFile::class, $queue->getFileInfo()); + $this->assertNotEmpty($queue->getQueueName()); + } + + public function testShouldCreateProducer() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $producer = $context->createProducer(); + + $this->assertInstanceOf(FsProducer::class, $producer); + } + + public function testShouldThrowIfNotFsDestinationGivenOnCreateConsumer() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Fs\FsDestination but got Enqueue\Null\NullQueue.'); + $consumer = $context->createConsumer(new NullQueue('aQueue')); + + $this->assertInstanceOf(FsConsumer::class, $consumer); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldCreateConsumer() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $context->createConsumer($queue); + } + + public function testShouldPropagatePreFetchCountToCreatedConsumer() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 123, 0666, 100); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertInstanceOf(FsConsumer::class, $consumer); + + $this->assertAttributeSame(123, 'preFetchCount', $consumer); + } + + public function testShouldAllowGetPreFetchCountSetInConstructor() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 123, 0666, 100); + + $this->assertSame(123, $context->getPreFetchCount()); + } + + public function testShouldAllowGetPreviouslySetPreFetchCount() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $context->setPreFetchCount(456); + + $this->assertSame(456, $context->getPreFetchCount()); + } + + public function testShouldAllowPurgeMessagesFromQueue() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + file_put_contents($tmpFile, 'foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $context->purgeQueue($queue); + + $this->assertEmpty(file_get_contents($tmpFile)); + } + + public function testShouldCreateFileOnFilesystemIfNotExistOnDeclareDestination() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/'.uniqid()); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $this->assertFileDoesNotExist((string) $tmpFile); + + $context->declareDestination($queue); + + $this->assertFileExists((string) $tmpFile); + $this->assertTrue(is_readable($tmpFile)); + $this->assertTrue(is_writable($tmpFile)); + + // do nothing if file already exists + $context->declareDestination($queue); + + $this->assertFileExists((string) $tmpFile); + + unlink($tmpFile); + } + + public function testShouldCreateMessageConsumerAndSetPollingInterval() + { + $tmpFile = new TempFile(sys_get_temp_dir().'/foo'); + + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 123456); + + $queue = $context->createQueue($tmpFile->getFilename()); + + $consumer = $context->createConsumer($queue); + + $this->assertInstanceOf(FsConsumer::class, $consumer); + $this->assertEquals(123456, $consumer->getPollingInterval()); + } +} diff --git a/pkg/fs/Tests/FsDestinationTest.php b/pkg/fs/Tests/FsDestinationTest.php new file mode 100644 index 000000000..6e5753f6f --- /dev/null +++ b/pkg/fs/Tests/FsDestinationTest.php @@ -0,0 +1,65 @@ +assertClassImplements(Topic::class, FsDestination::class); + $this->assertClassImplements(Queue::class, FsDestination::class); + } + + public function testCouldBeConstructedWithSplFileAsFirstArgument() + { + $splFile = new \SplFileInfo((string) TempFile::generate()); + + $destination = new FsDestination($splFile); + + $this->assertSame($splFile, $destination->getFileInfo()); + } + + public function testCouldBeConstructedWithTempFileAsFirstArgument() + { + $tmpFile = new TempFile((string) TempFile::generate()); + + $destination = new FsDestination($tmpFile); + + $this->assertSame($tmpFile, $destination->getFileInfo()); + } + + public function testShouldReturnFileNameOnGetNameCall() + { + $splFile = new \SplFileInfo((string) TempFile::generate()); + + $destination = new FsDestination($splFile); + + $this->assertSame($splFile->getFilename(), $destination->getName()); + } + + public function testShouldReturnFileNameOnGetQueueNameCall() + { + $splFile = new \SplFileInfo((string) TempFile::generate()); + + $destination = new FsDestination($splFile); + + $this->assertSame($splFile->getFilename(), $destination->getQueueName()); + } + + public function testShouldReturnFileNameOnGetTopicNameCall() + { + $splFile = new \SplFileInfo((string) TempFile::generate()); + + $destination = new FsDestination($splFile); + + $this->assertSame($splFile->getFilename(), $destination->getTopicName()); + } +} diff --git a/pkg/fs/Tests/FsMessageTest.php b/pkg/fs/Tests/FsMessageTest.php new file mode 100644 index 000000000..90655b620 --- /dev/null +++ b/pkg/fs/Tests/FsMessageTest.php @@ -0,0 +1,108 @@ +assertClassImplements(\JsonSerializable::class, FsMessage::class); + } + + public function testCouldConstructMessageWithoutArguments() + { + $message = new FsMessage(''); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getHeaders()); + $this->assertSame([], $message->getProperties()); + } + + public function testCouldConstructMessageWithBody() + { + $message = new FsMessage('body'); + + $this->assertSame('body', $message->getBody()); + } + + public function testCouldConstructMessageWithProperties() + { + $message = new FsMessage('', ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $message->getProperties()); + } + + public function testCouldConstructMessageWithHeaders() + { + $message = new FsMessage('', [], ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $message->getHeaders()); + } + + public function testShouldSetCorrelationIdAsHeader() + { + $message = new FsMessage(); + $message->setCorrelationId('the-correlation-id'); + + $this->assertSame(['correlation_id' => 'the-correlation-id'], $message->getHeaders()); + } + + public function testCouldSetMessageIdAsHeader() + { + $message = new FsMessage(); + $message->setMessageId('the-message-id'); + + $this->assertSame(['message_id' => 'the-message-id'], $message->getHeaders()); + } + + public function testCouldSetTimestampAsHeader() + { + $message = new FsMessage(); + $message->setTimestamp(12345); + + $this->assertSame(['timestamp' => 12345], $message->getHeaders()); + } + + public function testShouldAllowGetPreviouslySetReplyToAsHeader() + { + $message = new FsMessage(); + $message->setReplyTo('theQueueName'); + + $this->assertSame(['reply_to' => 'theQueueName'], $message->getHeaders()); + } + + public function testColdBeSerializedToJson() + { + $message = new FsMessage('theBody', ['thePropFoo' => 'thePropFooVal'], ['theHeaderFoo' => 'theHeaderFooVal']); + + $this->assertEquals('{"body":"theBody","properties":{"thePropFoo":"thePropFooVal"},"headers":{"theHeaderFoo":"theHeaderFooVal"}}', json_encode($message)); + } + + public function testCouldBeUnserializedFromJson() + { + $message = new FsMessage('theBody', ['thePropFoo' => 'thePropFooVal'], ['theHeaderFoo' => 'theHeaderFooVal']); + + $json = json_encode($message); + + // guard + $this->assertNotEmpty($json); + + $unserializedMessage = FsMessage::jsonUnserialize($json); + + $this->assertInstanceOf(FsMessage::class, $unserializedMessage); + $this->assertEquals($message, $unserializedMessage); + } + + public function testThrowIfMalformedJsonGivenOnUnsterilizedFromJson() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The malformed json given.'); + + FsMessage::jsonUnserialize('{]'); + } +} diff --git a/pkg/fs/Tests/FsProducerTest.php b/pkg/fs/Tests/FsProducerTest.php new file mode 100644 index 000000000..266854c7b --- /dev/null +++ b/pkg/fs/Tests/FsProducerTest.php @@ -0,0 +1,67 @@ +assertClassImplements(Producer::class, FsProducer::class); + } + + public function testThrowIfDestinationNotFsOnSend() + { + $producer = new FsProducer($this->createContextMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Fs\FsDestination but got Enqueue\Null\NullQueue.'); + $producer->send(new NullQueue('aQueue'), new FsMessage()); + } + + public function testThrowIfMessageNotFsOnSend() + { + $producer = new FsProducer($this->createContextMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Fs\FsMessage but it is Enqueue\Null\NullMessage.'); + $producer->send(new FsDestination(TempFile::generate()), new NullMessage()); + } + + public function testShouldCallContextWorkWithFileAndCallbackToItOnSend() + { + $destination = new FsDestination(TempFile::generate()); + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->once()) + ->method('workWithFile') + ->with($this->identicalTo($destination), 'a+', $this->isInstanceOf(\Closure::class)) + ; + + $producer = new FsProducer($contextMock); + + $producer->send($destination, new FsMessage()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|FsContext + */ + private function createContextMock() + { + return $this->createMock(FsContext::class); + } +} diff --git a/pkg/fs/Tests/Functional/FsCommonUseCasesTest.php b/pkg/fs/Tests/Functional/FsCommonUseCasesTest.php new file mode 100644 index 000000000..b96091e7f --- /dev/null +++ b/pkg/fs/Tests/Functional/FsCommonUseCasesTest.php @@ -0,0 +1,146 @@ +fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir()]))->createContext(); + + new TempFile(sys_get_temp_dir().'/fs_test_queue'); + } + + protected function tearDown(): void + { + $this->fsContext->close(); + } + + public function testWaitsForTwoSecondsAndReturnNullOnReceive() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $startAt = microtime(true); + + $consumer = $this->fsContext->createConsumer($queue); + $message = $consumer->receive(2000); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertGreaterThan(1.5, $endAt - $startAt); + $this->assertLessThan(2.5, $endAt - $startAt); + } + + public function testReturnNullImmediatelyOnReceiveNoWait() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $startAt = microtime(true); + + $consumer = $this->fsContext->createConsumer($queue); + $message = $consumer->receiveNoWait(); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertLessThan(0.5, $endAt - $startAt); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToQueue() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $message = $this->fsContext->createMessage( + __METHOD__, + ['FooProperty' => 'FooVal'], + ['BarHeader' => 'BarVal'] + ); + + $producer = $this->fsContext->createProducer(); + $producer->send($queue, $message); + + $consumer = $this->fsContext->createConsumer($queue); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(FsMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + $this->assertEquals(['FooProperty' => 'FooVal'], $message->getProperties()); + $this->assertEquals([ + 'BarHeader' => 'BarVal', + ], $message->getHeaders()); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToTemporaryQueue() + { + $queue = $this->fsContext->createTemporaryQueue(); + + $message = $this->fsContext->createMessage(__METHOD__); + + $producer = $this->fsContext->createProducer(); + $producer->send($queue, $message); + + $consumer = $this->fsContext->createConsumer($queue); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(FsMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + } + + public function testConsumerReceiveMessageWithZeroTimeout() + { + $topic = $this->fsContext->createTopic('fs_test_queue_exchange'); + + $consumer = $this->fsContext->createConsumer($topic); + // guard + $this->assertNull($consumer->receive(1000)); + + $message = $this->fsContext->createMessage(__METHOD__); + + $producer = $this->fsContext->createProducer(); + $producer->send($topic, $message); + usleep(100); + $actualMessage = $consumer->receive(0); + + $this->assertInstanceOf(FsMessage::class, $actualMessage); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + } + + public function testPurgeMessagesFromQueue() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $consumer = $this->fsContext->createConsumer($queue); + + $message = $this->fsContext->createMessage(__METHOD__); + + $producer = $this->fsContext->createProducer(); + $producer->send($queue, $message); + $producer->send($queue, $message); + + $this->fsContext->purgeQueue($queue); + + $this->assertNull($consumer->receive(1)); + } +} diff --git a/pkg/fs/Tests/Functional/FsConsumerTest.php b/pkg/fs/Tests/Functional/FsConsumerTest.php new file mode 100644 index 000000000..3be009b02 --- /dev/null +++ b/pkg/fs/Tests/Functional/FsConsumerTest.php @@ -0,0 +1,201 @@ +fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir()]))->createContext(); + + $this->fsContext->purgeQueue($this->fsContext->createQueue('fs_test_queue')); + } + + protected function tearDown(): void + { + $this->fsContext->close(); + } + + public function testShouldConsumeMessagesFromFileOneByOne() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + file_put_contents( + sys_get_temp_dir().'/fs_test_queue', + ' |{"body":"first message","properties":[],"headers":[]} |{"body":"second message","properties":[],"headers":[]} |{"body":"third message","properties":[],"headers":[]}' + ); + + $consumer = $this->fsContext->createConsumer($queue); + + $message = $consumer->receiveNoWait(); + $this->assertInstanceOf(FsMessage::class, $message); + $this->assertSame('third message', $message->getBody()); + + $this->assertSame( + ' |{"body":"first message","properties":[],"headers":[]} |{"body":"second message","properties":[],"headers":[]}', + file_get_contents(sys_get_temp_dir().'/fs_test_queue') + ); + + $message = $consumer->receiveNoWait(); + $this->assertInstanceOf(FsMessage::class, $message); + $this->assertSame('second message', $message->getBody()); + + $this->assertSame( + ' |{"body":"first message","properties":[],"headers":[]}', + file_get_contents(sys_get_temp_dir().'/fs_test_queue') + ); + + $message = $consumer->receiveNoWait(); + $this->assertInstanceOf(FsMessage::class, $message); + $this->assertSame('first message', $message->getBody()); + + $this->assertEmpty(file_get_contents(sys_get_temp_dir().'/fs_test_queue')); + + $message = $consumer->receiveNoWait(); + $this->assertNull($message); + + $this->assertEmpty(file_get_contents(sys_get_temp_dir().'/fs_test_queue')); + } + + /** + * @group bug + * @group bug170 + */ + public function testShouldNotFailOnSpecificMessageSize() + { + $context = $this->fsContext; + $queue = $context->createQueue('fs_test_queue'); + $context->purgeQueue($queue); + + $consumer = $context->createConsumer($queue); + $producer = $context->createProducer(); + + $producer->send($queue, $context->createMessage(str_repeat('a', 23))); + $producer->send($queue, $context->createMessage(str_repeat('b', 24))); + + $message = $consumer->receiveNoWait(); + $this->assertSame(str_repeat('b', 24), $message->getBody()); + + $message = $consumer->receiveNoWait(); + $this->assertSame(str_repeat('a', 23), $message->getBody()); + + $message = $consumer->receiveNoWait(); + $this->assertNull($message); + } + + /** + * @group bug + * @group bug170 + */ + public function testShouldNotCorruptFrameSize() + { + $context = $this->fsContext; + $queue = $context->createQueue('fs_test_queue'); + $context->purgeQueue($queue); + + $consumer = $context->createConsumer($queue); + $producer = $context->createProducer(); + + $producer->send($queue, $context->createMessage(str_repeat('a', 23))); + $producer->send($queue, $context->createMessage(str_repeat('b', 24))); + + $message = $consumer->receiveNoWait(); + $this->assertNotNull($message); + $context->workWithFile($queue, 'a+', function (FsDestination $destination, $file) { + $this->assertSame(0, fstat($file)['size'] % 64); + }); + + $message = $consumer->receiveNoWait(); + $this->assertNotNull($message); + $context->workWithFile($queue, 'a+', function (FsDestination $destination, $file) { + $this->assertSame(0, fstat($file)['size'] % 64); + }); + + $message = $consumer->receiveNoWait(); + $this->assertNull($message); + } + + /** + * @group bug + * @group bug202 + */ + public function testShouldThrowExceptionForTheCorruptedQueueFile() + { + $context = $this->fsContext; + $queue = $context->createQueue('fs_test_queue'); + $context->purgeQueue($queue); + + $context->workWithFile($queue, 'a+', function (FsDestination $destination, $file) { + fwrite($file, '|{"body":"{\"path\":\"\\\/p\\\/r\\\/pr_swoppad_6_4910_red_1.jpg\",\"filters\":null,\"force\":false}","properties":{"enqueue.topic_name":"liip_imagine_resolve_cache"},"headers":{"content_type":"application\/json","message_id":"46fdc345-5d0c-426e-95ac-227c7e657839","timestamp":1505379216,"reply_to":null,"correlation_id":""}} |{"body":"{\"path\":\"\\\/p\\\/r\\\/pr_swoppad_6_4910_black_1.jpg\",\"filters\":null,\"force\":false}","properties":{"enqueue.topic_name":"liip_imagine_resolve_cache"},"headers":{"content_type":"application\/json","message_id":"c4d60e39-3a8c-42df-b536-c8b7c13e006d","timestamp":1505379216,"reply_to":null,"correlation_id":""}} |{"body":"{\"path\":\"\\\/p\\\/r\\\/pr_swoppad_6_4910_green_1.jpg\",\"filters\":null,\"force\":false}","properties":{"enqueue.topic_name":"liip_imagine_resolve_cache"},"headers":{"content_type":"application\/json","message_id":"3a6aa176-c879-4435-9626-c48e0643defa","timestamp":1505379216,"reply_to":null,"correlation_id":""}}'); + }); + + $consumer = $context->createConsumer($queue); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The frame could start from either " " or "|". The malformed frame starts with """.'); + $consumer->receiveNoWait(); + } + + /** + * @group bug + * @group bug202 + */ + public function testShouldThrowExceptionWhenFrameSizeNotDivideExactly() + { + $context = $this->fsContext; + $queue = $context->createQueue('fs_test_queue'); + $context->purgeQueue($queue); + + $context->workWithFile($queue, 'a+', function (FsDestination $destination, $file) { + $msg = '|{"body":""}'; + // guard + $this->assertNotSame(0, strlen($msg) % 64); + + fwrite($file, $msg); + }); + + $consumer = $context->createConsumer($queue); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The frame size is "12" and it must divide exactly to 64 but it leaves a reminder "12".'); + $consumer->receiveNoWait(); + } + + /** + * @group bug + * @group bug390 + */ + public function testShouldUnEscapeDelimiterSymbolsInMessageBody() + { + $context = $this->fsContext; + $queue = $context->createQueue('fs_test_queue'); + $context->purgeQueue($queue); + + $message = $this->fsContext->createMessage(' |{"body":"aMessageData","properties":{"enqueue.topic_name":"user_updated"},"headers":{"content_type":"text\/plain","message_id":"90979b6c-d9ff-4b39-9938-878b83a95360","timestamp":1519899428,"reply_to":null,"correlation_id":""}}'); + + $this->fsContext->createProducer()->send($queue, $message); + + $this->assertSame(0, strlen(file_get_contents(sys_get_temp_dir().'/fs_test_queue')) % 64); + $this->assertSame( + ' |{"body":" \|\{\"body\":\"aMessageData\",\"properties\":{\"enqueue.topic_name\":\"user_updated\"},\"headers\":{\"content_type\":\"text\\\\\/plain\",\"message_id\":\"90979b6c-d9ff-4b39-9938-878b83a95360\",\"timestamp\":1519899428,\"reply_to\":null,\"correlation_id\":\"\"}}","properties":[],"headers":[]}', + file_get_contents(sys_get_temp_dir().'/fs_test_queue') + ); + + $consumer = $context->createConsumer($queue); + + $message = $consumer->receiveNoWait(); + + $this->assertSame(' |{"body":"aMessageData","properties":{"enqueue.topic_name":"user_updated"},"headers":{"content_type":"text\/plain","message_id":"90979b6c-d9ff-4b39-9938-878b83a95360","timestamp":1519899428,"reply_to":null,"correlation_id":""}}', $message->getBody()); + } +} diff --git a/pkg/fs/Tests/Functional/FsConsumptionUseCasesTest.php b/pkg/fs/Tests/Functional/FsConsumptionUseCasesTest.php new file mode 100644 index 000000000..334a8fe7d --- /dev/null +++ b/pkg/fs/Tests/Functional/FsConsumptionUseCasesTest.php @@ -0,0 +1,109 @@ +fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir()]))->createContext(); + + new TempFile(sys_get_temp_dir().'/fs_test_queue'); + } + + protected function tearDown(): void + { + $this->fsContext->close(); + } + + public function testConsumeOneMessageAndExit() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $message = $this->fsContext->createMessage(__METHOD__); + $this->fsContext->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->fsContext, new ChainExtension([ + new LimitConsumedMessagesExtension(1), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + ])); + + $processor = new StubProcessor(); + $queueConsumer->bind($queue, $processor); + + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + } + + public function testConsumeOneMessageAndSendReplyExit() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $replyQueue = $this->fsContext->createQueue('fs_test_queue_reply'); + + $message = $this->fsContext->createMessage(__METHOD__); + $message->setReplyTo($replyQueue->getQueueName()); + $this->fsContext->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->fsContext, new ChainExtension([ + new LimitConsumedMessagesExtension(2), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + new ReplyExtension(), + ])); + + $replyMessage = $this->fsContext->createMessage(__METHOD__.'.reply'); + + $processor = new StubProcessor(); + $processor->result = Result::reply($replyMessage); + + $replyProcessor = new StubProcessor(); + + $queueConsumer->bind($queue, $processor); + $queueConsumer->bind($replyQueue, $replyProcessor); + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + + $this->assertInstanceOf(Message::class, $replyProcessor->lastProcessedMessage); + $this->assertEquals(__METHOD__.'.reply', $replyProcessor->lastProcessedMessage->getBody()); + } +} + +class StubProcessor implements Processor +{ + public $result = self::ACK; + + /** @var Message */ + public $lastProcessedMessage; + + public function process(Message $message, Context $context) + { + $this->lastProcessedMessage = $message; + + return $this->result; + } +} diff --git a/pkg/fs/Tests/Functional/FsContextTest.php b/pkg/fs/Tests/Functional/FsContextTest.php new file mode 100644 index 000000000..806b9f56a --- /dev/null +++ b/pkg/fs/Tests/Functional/FsContextTest.php @@ -0,0 +1,32 @@ +remove(sys_get_temp_dir().'/enqueue'); + } + + public function testShouldCreateFoldersIfNotExistOnConstruct() + { + $fs = new Filesystem(); + $fs->remove(sys_get_temp_dir().'/enqueue'); + + $this->fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir().'/enqueue/dir/notexiststest']))->createContext(); + + $this->assertDirectoryExists(sys_get_temp_dir().'/enqueue/dir/notexiststest'); + } +} diff --git a/pkg/fs/Tests/Functional/FsProducerTest.php b/pkg/fs/Tests/Functional/FsProducerTest.php new file mode 100644 index 000000000..75625cfdd --- /dev/null +++ b/pkg/fs/Tests/Functional/FsProducerTest.php @@ -0,0 +1,48 @@ +fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir()]))->createContext(); + + new TempFile(sys_get_temp_dir().'/fs_test_queue'); + file_put_contents(sys_get_temp_dir().'/fs_test_queue', ''); + } + + protected function tearDown(): void + { + $this->fsContext->close(); + } + + public function testShouldStoreFilesToFileInExpectedFormat() + { + $queue = $this->fsContext->createQueue('fs_test_queue'); + + $firstMessage = $this->fsContext->createMessage('first message'); + $secondMessage = $this->fsContext->createMessage('second message'); + $thirdMessage = $this->fsContext->createMessage('third message'); + + $this->fsContext->createProducer()->send($queue, $firstMessage); + $this->fsContext->createProducer()->send($queue, $secondMessage); + $this->fsContext->createProducer()->send($queue, $thirdMessage); + + $this->assertSame(0, strlen(file_get_contents(sys_get_temp_dir().'/fs_test_queue')) % 64); + $this->assertSame( + ' |{"body":"first message","properties":[],"headers":[]} |{"body":"second message","properties":[],"headers":[]} |{"body":"third message","properties":[],"headers":[]}', + file_get_contents(sys_get_temp_dir().'/fs_test_queue') + ); + } +} diff --git a/pkg/fs/Tests/Functional/FsRpcUseCasesTest.php b/pkg/fs/Tests/Functional/FsRpcUseCasesTest.php new file mode 100644 index 000000000..3a0327d7c --- /dev/null +++ b/pkg/fs/Tests/Functional/FsRpcUseCasesTest.php @@ -0,0 +1,95 @@ +fsContext = (new FsConnectionFactory(['path' => sys_get_temp_dir()]))->createContext(); + + new TempFile(sys_get_temp_dir().'/fs_rpc_queue'); + new TempFile(sys_get_temp_dir().'/fs_reply_queue'); + } + + protected function tearDown(): void + { + $this->fsContext->close(); + } + + public function testDoAsyncRpcCallWithCustomReplyQueue() + { + $queue = $this->fsContext->createQueue('fs_rpc_queue'); + + $replyQueue = $this->fsContext->createQueue('fs_reply_queue'); + + $rpcClient = new RpcClient($this->fsContext); + + $message = $this->fsContext->createMessage(); + $message->setReplyTo($replyQueue->getQueueName()); + + $promise = $rpcClient->callAsync($queue, $message, 10); + $this->assertInstanceOf(Promise::class, $promise); + + $consumer = $this->fsContext->createConsumer($queue); + $message = $consumer->receive(1); + $this->assertInstanceOf(FsMessage::class, $message); + $this->assertNotNull($message->getReplyTo()); + $this->assertNotNull($message->getCorrelationId()); + $consumer->acknowledge($message); + + $replyQueue = $this->fsContext->createQueue($message->getReplyTo()); + $replyMessage = $this->fsContext->createMessage('This a reply!'); + $replyMessage->setCorrelationId($message->getCorrelationId()); + + $this->fsContext->createProducer()->send($replyQueue, $replyMessage); + + $actualReplyMessage = $promise->receive(); + $this->assertInstanceOf(FsMessage::class, $actualReplyMessage); + } + + public function testDoAsyncRecCallWithCastInternallyCreatedTemporaryReplyQueue() + { + $queue = $this->fsContext->createQueue('fs_rpc_queue'); + + $rpcClient = new RpcClient($this->fsContext); + + $message = $this->fsContext->createMessage(); + + $promise = $rpcClient->callAsync($queue, $message, 10); + $this->assertInstanceOf(Promise::class, $promise); + + $consumer = $this->fsContext->createConsumer($queue); + $receivedMessage = $consumer->receive(1); + + $this->assertInstanceOf(FsMessage::class, $receivedMessage); + $this->assertNotNull($receivedMessage->getReplyTo()); + $this->assertNotNull($receivedMessage->getCorrelationId()); + $consumer->acknowledge($receivedMessage); + + $replyQueue = $this->fsContext->createQueue($receivedMessage->getReplyTo()); + $replyMessage = $this->fsContext->createMessage('This a reply!'); + $replyMessage->setCorrelationId($receivedMessage->getCorrelationId()); + + $this->fsContext->createProducer()->send($replyQueue, $replyMessage); + + $actualReplyMessage = $promise->receive(); + $this->assertInstanceOf(FsMessage::class, $actualReplyMessage); + } +} diff --git a/pkg/fs/Tests/LegacyFilesystemLockTest.php b/pkg/fs/Tests/LegacyFilesystemLockTest.php new file mode 100644 index 000000000..519712881 --- /dev/null +++ b/pkg/fs/Tests/LegacyFilesystemLockTest.php @@ -0,0 +1,52 @@ +assertClassImplements(Lock::class, LegacyFilesystemLock::class); + } + + public function testShouldReleaseAllLocksOnClose() + { + $context = new FsContext(sys_get_temp_dir(), 1, 0666, 100); + $fooQueue = $context->createQueue('foo'); + $barQueue = $context->createTopic('bar'); + + new TempFile(sys_get_temp_dir().'/foo'); + new TempFile(sys_get_temp_dir().'/bar'); + + $lock = new LegacyFilesystemLock(); + + $this->assertAttributeCount(0, 'lockHandlers', $lock); + + $lock->lock($fooQueue); + $this->assertAttributeCount(1, 'lockHandlers', $lock); + + $lock->release($fooQueue); + $this->assertAttributeCount(1, 'lockHandlers', $lock); + + $lock->lock($barQueue); + $lock->lock($fooQueue); + $lock->lock($barQueue); + + $this->assertAttributeCount(2, 'lockHandlers', $lock); + + $lock->releaseAll(); + + $this->assertAttributeCount(0, 'lockHandlers', $lock); + } +} diff --git a/pkg/fs/Tests/Spec/FsMessageTest.php b/pkg/fs/Tests/Spec/FsMessageTest.php new file mode 100644 index 000000000..f1ece8ecb --- /dev/null +++ b/pkg/fs/Tests/Spec/FsMessageTest.php @@ -0,0 +1,14 @@ +createContext(); + } +} diff --git a/pkg/fs/composer.json b/pkg/fs/composer.json new file mode 100644 index 000000000..4dd2ff806 --- /dev/null +++ b/pkg/fs/composer.json @@ -0,0 +1,42 @@ +{ + "name": "enqueue/fs", + "type": "library", + "description": "Enqueue Filesystem based transport", + "keywords": ["messaging", "queue", "filesystem", "local"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/queue-interop": "^0.8", + "enqueue/dsn": "^0.10", + "symfony/filesystem": "^5.4|^6.0", + "makasim/temp-file": "^0.2@stable" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/null": "0.10.x-dev", + "enqueue/test": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Fs\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/fs/phpunit.xml.dist b/pkg/fs/phpunit.xml.dist new file mode 100644 index 000000000..79088ae1d --- /dev/null +++ b/pkg/fs/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + ./Tests + + + + + + + + + + . + + ./vendor + ./Resources + ./Tests + + + + diff --git a/pkg/gearman/.gitattributes b/pkg/gearman/.gitattributes new file mode 100644 index 000000000..e55879e9d --- /dev/null +++ b/pkg/gearman/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +.gitattributes export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/gearman/.github/workflows/ci.yml b/pkg/gearman/.github/workflows/ci.yml new file mode 100644 index 000000000..28ae81b0f --- /dev/null +++ b/pkg/gearman/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: gearman + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/gearman/GearmanConnectionFactory.php b/pkg/gearman/GearmanConnectionFactory.php new file mode 100644 index 000000000..c938a1025 --- /dev/null +++ b/pkg/gearman/GearmanConnectionFactory.php @@ -0,0 +1,87 @@ + 'localhost', + * 'port' => 11300 + * ] + * + * or + * + * gearman://host:port + * + * @param array|string $config + */ + public function __construct($config = 'gearman:') + { + if (empty($config) || 'gearman:' === $config) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return GearmanContext + */ + public function createContext(): Context + { + return new GearmanContext($this->config); + } + + private function parseDsn(string $dsn): array + { + $dsnConfig = parse_url($dsn); + if (false === $dsnConfig) { + throw new \LogicException(sprintf('Failed to parse DSN "%s"', $dsn)); + } + + $dsnConfig = array_replace([ + 'scheme' => null, + 'host' => null, + 'port' => null, + 'user' => null, + 'pass' => null, + 'path' => null, + 'query' => null, + ], $dsnConfig); + + if ('gearman' !== $dsnConfig['scheme']) { + throw new \LogicException(sprintf('The given DSN scheme "%s" is not supported. Could be "gearman" only.', $dsnConfig['scheme'])); + } + + return [ + 'port' => $dsnConfig['port'], + 'host' => $dsnConfig['host'], + ]; + } + + private function defaultConfig(): array + { + return [ + 'host' => \GEARMAN_DEFAULT_TCP_HOST, + 'port' => \GEARMAN_DEFAULT_TCP_PORT, + ]; + } +} diff --git a/pkg/gearman/GearmanConsumer.php b/pkg/gearman/GearmanConsumer.php new file mode 100644 index 000000000..e834fe469 --- /dev/null +++ b/pkg/gearman/GearmanConsumer.php @@ -0,0 +1,104 @@ +context = $context; + $this->destination = $destination; + + $this->worker = $context->createWorker(); + + $this->worker->addFunction($this->destination->getName(), function (\GearmanJob $job) { + $this->message = GearmanMessage::jsonUnserialize($job->workload()); + }); + } + + /** + * @return GearmanDestination + */ + public function getQueue(): Queue + { + return $this->destination; + } + + /** + * @return GearmanMessage + */ + public function receive(int $timeout = 0): ?Message + { + set_error_handler(function ($severity, $message, $file, $line) { + throw new \ErrorException($message, 0, $severity, $file, $line); + }); + + $this->worker->setTimeout($timeout); + + try { + $this->message = null; + + $this->worker->work(); + } finally { + restore_error_handler(); + } + + return $this->message; + } + + /** + * @return GearmanMessage + */ + public function receiveNoWait(): ?Message + { + return $this->receive(100); + } + + /** + * @param GearmanMessage $message + */ + public function acknowledge(Message $message): void + { + } + + /** + * @param GearmanMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + if ($requeue) { + $this->context->createProducer()->send($this->destination, $message); + } + } + + public function getWorker(): \GearmanWorker + { + return $this->worker; + } +} diff --git a/pkg/gearman/GearmanContext.php b/pkg/gearman/GearmanContext.php new file mode 100644 index 000000000..80a93882e --- /dev/null +++ b/pkg/gearman/GearmanContext.php @@ -0,0 +1,129 @@ +config = $config; + } + + /** + * @return GearmanMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new GearmanMessage($body, $properties, $headers); + } + + /** + * @return GearmanDestination + */ + public function createTopic(string $topicName): Topic + { + return new GearmanDestination($topicName); + } + + /** + * @return GearmanDestination + */ + public function createQueue(string $queueName): Queue + { + return new GearmanDestination($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return GearmanProducer + */ + public function createProducer(): Producer + { + return new GearmanProducer($this->getClient()); + } + + /** + * @param GearmanDestination $destination + * + * @return GearmanConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, GearmanDestination::class); + + $this->consumers[] = $consumer = new GearmanConsumer($this, $destination); + + return $consumer; + } + + public function close(): void + { + $this->getClient()->clearCallbacks(); + + foreach ($this->consumers as $consumer) { + $consumer->getWorker()->unregisterAll(); + } + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function purgeQueue(Queue $queue): void + { + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function getClient(): \GearmanClient + { + if (false == $this->client) { + $this->client = new \GearmanClient(); + $this->client->addServer($this->config['host'], $this->config['port']); + } + + return $this->client; + } + + public function createWorker(): \GearmanWorker + { + $worker = new \GearmanWorker(); + $worker->addServer($this->config['host'], $this->config['port']); + + return $worker; + } +} diff --git a/pkg/gearman/GearmanDestination.php b/pkg/gearman/GearmanDestination.php new file mode 100644 index 000000000..c559d6126 --- /dev/null +++ b/pkg/gearman/GearmanDestination.php @@ -0,0 +1,36 @@ +destinationName = $destinationName; + } + + public function getName(): string + { + return $this->destinationName; + } + + public function getQueueName(): string + { + return $this->destinationName; + } + + public function getTopicName(): string + { + return $this->destinationName; + } +} diff --git a/pkg/gearman/GearmanMessage.php b/pkg/gearman/GearmanMessage.php new file mode 100644 index 000000000..ee93a78dd --- /dev/null +++ b/pkg/gearman/GearmanMessage.php @@ -0,0 +1,174 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', (string) $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', (string) $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function jsonSerialize(): array + { + return [ + 'body' => $this->getBody(), + 'properties' => $this->getProperties(), + 'headers' => $this->getHeaders(), + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['body'], $data['properties'], $data['headers']); + } + + public function getJob(): ?\GearmanJob + { + return $this->job; + } + + public function setJob(?\GearmanJob $job = null): void + { + $this->job = $job; + } +} diff --git a/pkg/gearman/GearmanProducer.php b/pkg/gearman/GearmanProducer.php new file mode 100644 index 000000000..870bdcb03 --- /dev/null +++ b/pkg/gearman/GearmanProducer.php @@ -0,0 +1,84 @@ +client = $client; + } + + /** + * @param GearmanDestination $destination + * @param GearmanMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, GearmanDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, GearmanMessage::class); + + $this->client->doBackground($destination->getName(), json_encode($message)); + + $code = $this->client->returnCode(); + if (\GEARMAN_SUCCESS !== $code) { + throw new \GearmanException(sprintf('The return code is not %s (GEARMAN_SUCCESS) but %s', \GEARMAN_SUCCESS, $code)); + } + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/gearman/LICENSE b/pkg/gearman/LICENSE new file mode 100644 index 000000000..d9736f8bf --- /dev/null +++ b/pkg/gearman/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/gearman/README.md b/pkg/gearman/README.md new file mode 100644 index 000000000..4aedb72d2 --- /dev/null +++ b/pkg/gearman/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Gearman Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/gearman/ci.yml?branch=master)](https://github.com/php-enqueue/gearman/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/gearman/d/total.png)](https://packagist.org/packages/enqueue/gearman) +[![Latest Stable Version](https://poser.pugx.org/enqueue/gearman/version.png)](https://packagist.org/packages/enqueue/gearman) + +This is an implementation of the queue specification. It allows you to send and consume message from Gearman broker. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/gearman/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/gearman/Tests/GearmanConnectionFactoryConfigTest.php b/pkg/gearman/Tests/GearmanConnectionFactoryConfigTest.php new file mode 100644 index 000000000..8fc7a6b1e --- /dev/null +++ b/pkg/gearman/Tests/GearmanConnectionFactoryConfigTest.php @@ -0,0 +1,103 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new GearmanConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotGearmanAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN scheme "http" is not supported. Could be "gearman" only.'); + + new GearmanConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to parse DSN "gearman://:@/"'); + + new GearmanConnectionFactory('gearman://:@/'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new GearmanConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'host' => 'localhost', + 'port' => 4730, + ], + ]; + + yield [ + 'gearman:', + [ + 'host' => 'localhost', + 'port' => 4730, + ], + ]; + + yield [ + [], + [ + 'host' => 'localhost', + 'port' => 4730, + ], + ]; + + yield [ + 'gearman://theHost:1234', + [ + 'host' => 'theHost', + 'port' => 1234, + ], + ]; + + yield [ + ['host' => 'theHost', 'port' => 1234], + [ + 'host' => 'theHost', + 'port' => 1234, + ], + ]; + + yield [ + ['host' => 'theHost'], + [ + 'host' => 'theHost', + 'port' => 4730, + ], + ]; + } +} diff --git a/pkg/gearman/Tests/GearmanContextTest.php b/pkg/gearman/Tests/GearmanContextTest.php new file mode 100644 index 000000000..8a36ad80b --- /dev/null +++ b/pkg/gearman/Tests/GearmanContextTest.php @@ -0,0 +1,43 @@ +assertClassImplements(Context::class, GearmanContext::class); + } + + public function testThrowNotImplementedOnCreateTemporaryQueue() + { + $context = new GearmanContext(['host' => 'aHost', 'port' => 'aPort']); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + public function testThrowInvalidDestinationIfInvalidDestinationGivenOnCreateConsumer() + { + $context = new GearmanContext(['host' => 'aHost', 'port' => 'aPort']); + + $this->expectException(InvalidDestinationException::class); + $context->createConsumer(new NullQueue('aQueue')); + } +} diff --git a/pkg/gearman/Tests/GearmanDestinationTest.php b/pkg/gearman/Tests/GearmanDestinationTest.php new file mode 100644 index 000000000..b98f09fee --- /dev/null +++ b/pkg/gearman/Tests/GearmanDestinationTest.php @@ -0,0 +1,32 @@ +assertClassImplements(Queue::class, GearmanDestination::class); + } + + public function testShouldImplementTopicInterface() + { + $this->assertClassImplements(Topic::class, GearmanDestination::class); + } + + public function testShouldAllowGetNameSetInConstructor() + { + $destination = new GearmanDestination('theDestinationName'); + + $this->assertSame('theDestinationName', $destination->getName()); + } +} diff --git a/pkg/gearman/Tests/GearmanMessageTest.php b/pkg/gearman/Tests/GearmanMessageTest.php new file mode 100644 index 000000000..5eaa8f070 --- /dev/null +++ b/pkg/gearman/Tests/GearmanMessageTest.php @@ -0,0 +1,23 @@ +setJob($job); + + $this->assertSame($job, $message->getJob()); + } +} diff --git a/pkg/gearman/Tests/GearmanProducerTest.php b/pkg/gearman/Tests/GearmanProducerTest.php new file mode 100644 index 000000000..2a7baa4de --- /dev/null +++ b/pkg/gearman/Tests/GearmanProducerTest.php @@ -0,0 +1,73 @@ +createGearmanClientMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Gearman\GearmanDestination but got Enqueue\Null\NullQueue.'); + $producer->send(new NullQueue('aQueue'), new GearmanMessage()); + } + + public function testThrowIfMessageInvalid() + { + $producer = new GearmanProducer($this->createGearmanClientMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Gearman\GearmanMessage but it is Enqueue\Null\NullMessage.'); + $producer->send(new GearmanDestination('aQueue'), new NullMessage()); + } + + public function testShouldJsonEncodeMessageAndPutToExpectedTube() + { + $message = new GearmanMessage('theBody', ['foo' => 'fooVal'], ['bar' => 'barVal']); + + $gearman = $this->createGearmanClientMock(); + $gearman + ->expects($this->once()) + ->method('doBackground') + ->with( + 'theQueueName', + '{"body":"theBody","properties":{"foo":"fooVal"},"headers":{"bar":"barVal"}}' + ) + ; + $gearman + ->expects($this->once()) + ->method('returnCode') + ->willReturn(\GEARMAN_SUCCESS) + ; + + $producer = new GearmanProducer($gearman); + + $producer->send( + new GearmanDestination('theQueueName'), + $message + ); + } + + /** + * @return MockObject|\GearmanClient + */ + private function createGearmanClientMock() + { + return $this->createMock(\GearmanClient::class); + } +} diff --git a/pkg/gearman/Tests/SkipIfGearmanExtensionIsNotInstalledTrait.php b/pkg/gearman/Tests/SkipIfGearmanExtensionIsNotInstalledTrait.php new file mode 100644 index 000000000..9c680bb67 --- /dev/null +++ b/pkg/gearman/Tests/SkipIfGearmanExtensionIsNotInstalledTrait.php @@ -0,0 +1,15 @@ +markTestSkipped('The gearman extension is not installed'); + } + + parent::setUp(); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanConnectionFactoryTest.php b/pkg/gearman/Tests/Spec/GearmanConnectionFactoryTest.php new file mode 100644 index 000000000..05418febc --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanConnectionFactoryTest.php @@ -0,0 +1,17 @@ +createContext(); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanMessageTest.php b/pkg/gearman/Tests/Spec/GearmanMessageTest.php new file mode 100644 index 000000000..37aa71e62 --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanMessageTest.php @@ -0,0 +1,17 @@ +createContext(); + } + + /** + * @param string $queueName + * + * @return Queue + */ + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.time()); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanSendToAndReceiveNoWaitFromQueueTest.php b/pkg/gearman/Tests/Spec/GearmanSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..e2164ea7a --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,26 @@ +createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.time()); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveFromQueueTest.php b/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..32463cce1 --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,38 @@ +time = time(); + } + + protected function createContext() + { + $factory = new GearmanConnectionFactory(getenv('GEARMAN_DSN')); + + return $factory->createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.$this->time); + } + + protected function createTopic(Context $context, $topicName) + { + return $context->createTopic($topicName.$this->time); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..993dc3f25 --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,38 @@ +time = time(); + } + + protected function createContext() + { + $factory = new GearmanConnectionFactory(getenv('GEARMAN_DSN')); + + return $factory->createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.$this->time); + } + + protected function createTopic(Context $context, $topicName) + { + return $context->createTopic($topicName.$this->time); + } +} diff --git a/pkg/gearman/Tests/Spec/GearmanTopicTest.php b/pkg/gearman/Tests/Spec/GearmanTopicTest.php new file mode 100644 index 000000000..826344e8b --- /dev/null +++ b/pkg/gearman/Tests/Spec/GearmanTopicTest.php @@ -0,0 +1,17 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/gps/.gitattributes b/pkg/gps/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/gps/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/gps/.github/workflows/ci.yml b/pkg/gps/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/gps/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/gps/.gitignore b/pkg/gps/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/gps/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/gps/GpsConnectionFactory.php b/pkg/gps/GpsConnectionFactory.php new file mode 100644 index 000000000..e45f8cbe3 --- /dev/null +++ b/pkg/gps/GpsConnectionFactory.php @@ -0,0 +1,123 @@ + The project ID from the Google Developer's Console. + * 'keyFilePath' => The full path to your service account credentials.json file retrieved from the Google Developers Console. + * 'retries' => Number of retries for a failed request. **Defaults to** `3`. + * 'scopes' => Scopes to be used for the request. + * 'emulatorHost' => The endpoint used to emulate communication with GooglePubSub. + * 'lazy' => 'the connection will be performed as later as possible, if the option set to true' + * ] + * + * or + * + * gps: + * gps:?projectId=projectName + * + * or instance of Google\Cloud\PubSub\PubSubClient + * + * @param array|string|PubSubClient|null $config + */ + public function __construct($config = 'gps:') + { + if ($config instanceof PubSubClient) { + $this->client = $config; + $this->config = ['lazy' => false] + $this->defaultConfig(); + + return; + } + + if (empty($config)) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace_recursive($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', PubSubClient::class)); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return GpsContext + */ + public function createContext(): Context + { + if ($this->config['lazy']) { + return new GpsContext(function () { + return $this->establishConnection(); + }); + } + + return new GpsContext($this->establishConnection()); + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('gps' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "gps"', $dsn->getSchemeProtocol())); + } + + $emulatorHost = $dsn->getString('emulatorHost'); + $hasEmulator = $emulatorHost ? true : null; + + return array_filter(array_replace($dsn->getQuery(), [ + 'projectId' => $dsn->getString('projectId'), + 'keyFilePath' => $dsn->getString('keyFilePath'), + 'retries' => $dsn->getDecimal('retries'), + 'scopes' => $dsn->getString('scopes'), + 'emulatorHost' => $emulatorHost, + 'hasEmulator' => $hasEmulator, + 'lazy' => $dsn->getBool('lazy'), + ]), function ($value) { return null !== $value; }); + } + + private function establishConnection(): PubSubClient + { + if (false == $this->client) { + $this->client = new PubSubClient($this->config); + } + + return $this->client; + } + + private function defaultConfig(): array + { + return [ + 'lazy' => true, + ]; + } +} diff --git a/pkg/gps/GpsConsumer.php b/pkg/gps/GpsConsumer.php new file mode 100644 index 000000000..e2a1be272 --- /dev/null +++ b/pkg/gps/GpsConsumer.php @@ -0,0 +1,137 @@ +context = $context; + $this->queue = $queue; + } + + /** + * @return GpsQueue + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return GpsMessage + */ + public function receive(int $timeout = 0): ?Message + { + if (0 === $timeout) { + while (true) { + if ($message = $this->receiveMessage($timeout)) { + return $message; + } + } + } else { + return $this->receiveMessage($timeout); + } + } + + /** + * @return GpsMessage + */ + public function receiveNoWait(): ?Message + { + $messages = $this->getSubscription()->pull([ + 'maxMessages' => 1, + 'returnImmediately' => true, + ]); + + if ($messages) { + return $this->convertMessage(current($messages)); + } + + return null; + } + + /** + * @param GpsMessage $message + */ + public function acknowledge(Message $message): void + { + if (false == $message->getNativeMessage()) { + throw new \LogicException('Native google pub/sub message required but it is empty'); + } + + $this->getSubscription()->acknowledge($message->getNativeMessage()); + } + + /** + * @param GpsMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + if (false == $message->getNativeMessage()) { + throw new \LogicException('Native google pub/sub message required but it is empty'); + } + + $this->getSubscription()->acknowledge($message->getNativeMessage()); + } + + private function getSubscription(): Subscription + { + if (null === $this->subscription) { + $this->subscription = $this->context->getClient()->subscription($this->queue->getQueueName()); + } + + return $this->subscription; + } + + private function convertMessage(GoogleMessage $message): GpsMessage + { + $gpsMessage = GpsMessage::jsonUnserialize($message->data()); + $gpsMessage->setNativeMessage($message); + + return $gpsMessage; + } + + private function receiveMessage(int $timeout): ?GpsMessage + { + $timeout /= 1000; + + try { + $messages = $this->getSubscription()->pull([ + 'maxMessages' => 1, + 'requestTimeout' => $timeout, + ]); + + if ($messages) { + return $this->convertMessage(current($messages)); + } + } catch (ServiceException $e) { + } // timeout + + return null; + } +} diff --git a/pkg/gps/GpsContext.php b/pkg/gps/GpsContext.php new file mode 100644 index 000000000..27625992a --- /dev/null +++ b/pkg/gps/GpsContext.php @@ -0,0 +1,155 @@ +options = array_replace([ + 'ackDeadlineSeconds' => 10, + ], $options); + + if ($client instanceof PubSubClient) { + $this->client = $client; + } elseif (is_callable($client)) { + $this->clientFactory = $client; + } else { + throw new \InvalidArgumentException(sprintf('The $client argument must be either %s or callable that returns %s once called.', PubSubClient::class, PubSubClient::class)); + } + } + + /** + * @return GpsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new GpsMessage($body, $properties, $headers); + } + + /** + * @return GpsTopic + */ + public function createTopic(string $topicName): Topic + { + return new GpsTopic($topicName); + } + + /** + * @return GpsQueue + */ + public function createQueue(string $queueName): Queue + { + return new GpsQueue($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return GpsProducer + */ + public function createProducer(): Producer + { + return new GpsProducer($this); + } + + /** + * @param GpsQueue|GpsTopic $destination + * + * @return GpsConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, GpsQueue::class); + + return new GpsConsumer($this, $destination); + } + + public function close(): void + { + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function purgeQueue(Queue $queue): void + { + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function declareTopic(GpsTopic $topic): void + { + try { + $this->getClient()->createTopic($topic->getTopicName()); + } catch (ConflictException $e) { + } + } + + public function subscribe(GpsTopic $topic, GpsQueue $queue): void + { + $this->declareTopic($topic); + + try { + $this->getClient()->subscribe($queue->getQueueName(), $topic->getTopicName(), [ + 'ackDeadlineSeconds' => $this->options['ackDeadlineSeconds'], + ]); + } catch (ConflictException $e) { + } + } + + public function getClient(): PubSubClient + { + if (false == $this->client) { + $client = call_user_func($this->clientFactory); + if (false == $client instanceof PubSubClient) { + throw new \LogicException(sprintf('The factory must return instance of %s. It returned %s', PubSubClient::class, is_object($client) ? $client::class : gettype($client))); + } + + $this->client = $client; + } + + return $this->client; + } +} diff --git a/pkg/gps/GpsMessage.php b/pkg/gps/GpsMessage.php new file mode 100644 index 000000000..b7e2bf484 --- /dev/null +++ b/pkg/gps/GpsMessage.php @@ -0,0 +1,176 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + + $this->redelivered = false; + } + + public function getBody(): string + { + return $this->body; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = (bool) $redelivered; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function jsonSerialize(): array + { + return [ + 'body' => $this->getBody(), + 'properties' => $this->getProperties(), + 'headers' => $this->getHeaders(), + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['body'] ?? $json, $data['properties'] ?? [], $data['headers'] ?? []); + } + + public function getNativeMessage(): ?GoogleMessage + { + return $this->nativeMessage; + } + + public function setNativeMessage(?GoogleMessage $message = null): void + { + $this->nativeMessage = $message; + } +} diff --git a/pkg/gps/GpsProducer.php b/pkg/gps/GpsProducer.php new file mode 100644 index 000000000..7e307636f --- /dev/null +++ b/pkg/gps/GpsProducer.php @@ -0,0 +1,91 @@ +context = $context; + } + + /** + * @param GpsTopic $destination + * @param GpsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, GpsTopic::class); + InvalidMessageException::assertMessageInstanceOf($message, GpsMessage::class); + + /** @var Topic $topic */ + $topic = $this->context->getClient()->topic($destination->getTopicName()); + + $params = ['data' => json_encode($message)]; + + if (count($message->getHeaders()) > 0) { + $params['attributes'] = $message->getHeaders(); + } + + $topic->publish($params); + } + + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw TimeToLiveNotSupportedException::providerDoestNotSupportIt(); + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/gps/GpsQueue.php b/pkg/gps/GpsQueue.php new file mode 100644 index 000000000..1c4f1c785 --- /dev/null +++ b/pkg/gps/GpsQueue.php @@ -0,0 +1,25 @@ +name = $name; + } + + public function getQueueName(): string + { + return $this->name; + } +} diff --git a/pkg/gps/GpsTopic.php b/pkg/gps/GpsTopic.php new file mode 100644 index 000000000..73646c46a --- /dev/null +++ b/pkg/gps/GpsTopic.php @@ -0,0 +1,25 @@ +name = $name; + } + + public function getTopicName(): string + { + return $this->name; + } +} diff --git a/pkg/gps/LICENSE b/pkg/gps/LICENSE new file mode 100644 index 000000000..f1e6a22fe --- /dev/null +++ b/pkg/gps/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2016 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/gps/README.md b/pkg/gps/README.md new file mode 100644 index 000000000..4f2a0e6ac --- /dev/null +++ b/pkg/gps/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Google Pub/Sub Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/gps/ci.yml?branch=master)](https://github.com/php-enqueue/gps/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/gps/d/total.png)](https://packagist.org/packages/enqueue/gps) +[![Latest Stable Version](https://poser.pugx.org/enqueue/gps/version.png)](https://packagist.org/packages/enqueue/gps) + +This is an implementation of Queue Interop specification. It allows you to send and consume message through Google Pub/Sub library. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/gps/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/gps/Tests/GpsConnectionFactoryConfigTest.php b/pkg/gps/Tests/GpsConnectionFactoryConfigTest.php new file mode 100644 index 000000000..474149497 --- /dev/null +++ b/pkg/gps/Tests/GpsConnectionFactoryConfigTest.php @@ -0,0 +1,107 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string, null or instance of Google\Cloud\PubSub\PubSubClient'); + + new GpsConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be "gps"'); + + new GpsConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new GpsConnectionFactory('foo'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new GpsConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'lazy' => true, + ], + ]; + + yield [ + 'gps:', + [ + 'lazy' => true, + ], + ]; + + yield [ + [], + [ + 'lazy' => true, + ], + ]; + + yield [ + 'gps:?foo=fooVal&projectId=mqdev&emulatorHost=http%3A%2F%2Fgoogle-pubsub%3A8085', + [ + 'foo' => 'fooVal', + 'projectId' => 'mqdev', + 'emulatorHost' => 'http://google-pubsub:8085', + 'hasEmulator' => true, + 'lazy' => true, + ], + ]; + + yield [ + ['dsn' => 'gps:?foo=fooVal&projectId=mqdev&emulatorHost=http%3A%2F%2Fgoogle-pubsub%3A8085'], + [ + 'foo' => 'fooVal', + 'projectId' => 'mqdev', + 'emulatorHost' => 'http://google-pubsub:8085', + 'hasEmulator' => true, + 'lazy' => true, + ], + ]; + + yield [ + ['foo' => 'fooVal', 'projectId' => 'mqdev', 'emulatorHost' => 'http://Fgoogle-pubsub:8085', 'lazy' => false], + [ + 'foo' => 'fooVal', + 'projectId' => 'mqdev', + 'emulatorHost' => 'http://Fgoogle-pubsub:8085', + 'lazy' => false, + ], + ]; + } +} diff --git a/pkg/gps/Tests/GpsConsumerTest.php b/pkg/gps/Tests/GpsConsumerTest.php new file mode 100644 index 000000000..0b4b925ce --- /dev/null +++ b/pkg/gps/Tests/GpsConsumerTest.php @@ -0,0 +1,205 @@ +createContextMock(), new GpsQueue('')); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Native google pub/sub message required but it is empty'); + + $consumer->acknowledge(new GpsMessage('')); + } + + public function testShouldAcknowledgeMessage() + { + $nativeMessage = new Message([], []); + + $subscription = $this->createSubscriptionMock(); + $subscription + ->expects($this->once()) + ->method('acknowledge') + ->with($this->identicalTo($nativeMessage)) + ; + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscription') + ->willReturn($subscription) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $consumer = new GpsConsumer($context, new GpsQueue('queue-name')); + + $message = new GpsMessage(''); + $message->setNativeMessage($nativeMessage); + + $consumer->acknowledge($message); + } + + public function testRejectShouldThrowExceptionIfNativeMessageNotSet() + { + $consumer = new GpsConsumer($this->createContextMock(), new GpsQueue('')); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Native google pub/sub message required but it is empty'); + + $consumer->acknowledge(new GpsMessage('')); + } + + public function testShouldRejectMessage() + { + $nativeMessage = new Message([], []); + + $subscription = $this->createSubscriptionMock(); + $subscription + ->expects($this->once()) + ->method('acknowledge') + ->with($this->identicalTo($nativeMessage)) + ; + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscription') + ->willReturn($subscription) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $consumer = new GpsConsumer($context, new GpsQueue('queue-name')); + + $message = new GpsMessage(''); + $message->setNativeMessage($nativeMessage); + + $consumer->reject($message); + } + + public function testShouldReceiveMessageNoWait() + { + $message = new GpsMessage('the body'); + $nativeMessage = new Message([ + 'data' => json_encode($message), + ], []); + + $subscription = $this->createSubscriptionMock(); + $subscription + ->expects($this->once()) + ->method('pull') + ->with($this->identicalTo([ + 'maxMessages' => 1, + 'returnImmediately' => true, + ])) + ->willReturn([$nativeMessage]) + ; + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscription') + ->willReturn($subscription) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $consumer = new GpsConsumer($context, new GpsQueue('queue-name')); + + $message = $consumer->receiveNoWait(); + + $this->assertInstanceOf(GpsMessage::class, $message); + $this->assertSame('the body', $message->getBody()); + } + + public function testShouldReceiveMessage() + { + $message = new GpsMessage('the body'); + $nativeMessage = new Message([ + 'data' => json_encode($message), + ], []); + + $subscription = $this->createSubscriptionMock(); + $subscription + ->expects($this->once()) + ->method('pull') + ->with($this->identicalTo([ + 'maxMessages' => 1, + 'requestTimeout' => 12.345, + ])) + ->willReturn([$nativeMessage]) + ; + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscription') + ->willReturn($subscription) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $consumer = new GpsConsumer($context, new GpsQueue('queue-name')); + + $message = $consumer->receive(12345); + + $this->assertInstanceOf(GpsMessage::class, $message); + $this->assertSame('the body', $message->getBody()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|GpsContext + */ + private function createContextMock() + { + return $this->createMock(GpsContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|PubSubClient + */ + private function createPubSubClientMock() + { + return $this->createMock(PubSubClient::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Subscription + */ + private function createSubscriptionMock() + { + return $this->createMock(Subscription::class); + } +} diff --git a/pkg/gps/Tests/GpsContextTest.php b/pkg/gps/Tests/GpsContextTest.php new file mode 100644 index 000000000..658e2659e --- /dev/null +++ b/pkg/gps/Tests/GpsContextTest.php @@ -0,0 +1,96 @@ +createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('createTopic') + ->with('topic-name') + ; + + $topic = new GpsTopic('topic-name'); + + $context = new GpsContext($client); + $context->declareTopic($topic); + } + + public function testDeclareTopicShouldCatchConflictException() + { + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('createTopic') + ->willThrowException(new ConflictException('')) + ; + + $topic = new GpsTopic(''); + + $context = new GpsContext($client); + $context->declareTopic($topic); + } + + public function testShouldSubscribeTopicToQueue() + { + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscribe') + ->with('queue-name', 'topic-name', $this->identicalTo(['ackDeadlineSeconds' => 10])) + ; + + $topic = new GpsTopic('topic-name'); + $queue = new GpsQueue('queue-name'); + + $context = new GpsContext($client); + + $context->subscribe($topic, $queue); + } + + public function testSubscribeShouldCatchConflictException() + { + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('subscribe') + ->willThrowException(new ConflictException('')) + ; + + $topic = new GpsTopic('topic-name'); + $queue = new GpsQueue('queue-name'); + + $context = new GpsContext($client); + + $context->subscribe($topic, $queue); + } + + public function testCreateConsumerShouldThrowExceptionIfInvalidDestination() + { + $context = new GpsContext($this->createPubSubClientMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Gps\GpsQueue but got Enqueue\Gps\GpsTopic'); + + $context->createConsumer(new GpsTopic('')); + } + + /** + * @return PubSubClient|\PHPUnit\Framework\MockObject\MockObject|PubSubClient + */ + private function createPubSubClientMock() + { + return $this->createMock(PubSubClient::class); + } +} diff --git a/pkg/gps/Tests/GpsMessageTest.php b/pkg/gps/Tests/GpsMessageTest.php new file mode 100644 index 000000000..8182333cc --- /dev/null +++ b/pkg/gps/Tests/GpsMessageTest.php @@ -0,0 +1,73 @@ +setNativeMessage($nativeMessage = new Message([], [])); + + $this->assertSame($nativeMessage, $message->getNativeMessage()); + } + + public function testColdBeSerializedToJson() + { + $message = new GpsMessage('theBody', ['thePropFoo' => 'thePropFooVal'], ['theHeaderFoo' => 'theHeaderFooVal']); + + $this->assertEquals('{"body":"theBody","properties":{"thePropFoo":"thePropFooVal"},"headers":{"theHeaderFoo":"theHeaderFooVal"}}', json_encode($message)); + } + + public function testCouldBeUnserializedFromJson() + { + $message = new GpsMessage('theBody', ['thePropFoo' => 'thePropFooVal'], ['theHeaderFoo' => 'theHeaderFooVal']); + + $json = json_encode($message); + + // guard + $this->assertNotEmpty($json); + + $unserializedMessage = GpsMessage::jsonUnserialize($json); + + $this->assertInstanceOf(GpsMessage::class, $unserializedMessage); + $this->assertEquals($message, $unserializedMessage); + } + + public function testMessageEntityCouldBeUnserializedFromJson() + { + $json = '{"body":"theBody","properties":{"thePropFoo":"thePropFooVal"},"headers":{"theHeaderFoo":"theHeaderFooVal"}}'; + + $unserializedMessage = GpsMessage::jsonUnserialize($json); + + $this->assertInstanceOf(GpsMessage::class, $unserializedMessage); + $decoded = json_decode($json, true); + $this->assertEquals($decoded['body'], $unserializedMessage->getBody()); + $this->assertEquals($decoded['properties'], $unserializedMessage->getProperties()); + $this->assertEquals($decoded['headers'], $unserializedMessage->getHeaders()); + } + + public function testMessagePayloadCouldBeUnserializedFromJson() + { + $json = '{"theBodyPropFoo":"theBodyPropVal"}'; + + $unserializedMessage = GpsMessage::jsonUnserialize($json); + + $this->assertInstanceOf(GpsMessage::class, $unserializedMessage); + $this->assertEquals($json, $unserializedMessage->getBody()); + $this->assertEquals([], $unserializedMessage->getProperties()); + $this->assertEquals([], $unserializedMessage->getHeaders()); + } + + public function testThrowIfMalformedJsonGivenOnUnsterilizedFromJson() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The malformed json given.'); + + GpsMessage::jsonUnserialize('{]'); + } +} diff --git a/pkg/gps/Tests/GpsProducerTest.php b/pkg/gps/Tests/GpsProducerTest.php new file mode 100644 index 000000000..3079d3c7c --- /dev/null +++ b/pkg/gps/Tests/GpsProducerTest.php @@ -0,0 +1,113 @@ +createContextMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Gps\GpsTopic but got Enqueue\Gps\GpsQueue'); + + $producer->send(new GpsQueue(''), new GpsMessage('')); + } + + public function testShouldSendMessage() + { + $topic = new GpsTopic('topic-name'); + $message = new GpsMessage(''); + + $gtopic = $this->createGTopicMock(); + $gtopic + ->expects($this->once()) + ->method('publish') + ->with($this->identicalTo([ + 'data' => '{"body":"","properties":[],"headers":[]}', + ])); + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('topic') + ->with('topic-name') + ->willReturn($gtopic) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $producer = new GpsProducer($context); + $producer->send($topic, $message); + } + + public function testShouldSendMessageWithHeaders() + { + $topic = new GpsTopic('topic-name'); + $message = new GpsMessage('', [], ['key1' => 'value1']); + + $gtopic = $this->createGTopicMock(); + $gtopic + ->expects($this->once()) + ->method('publish') + ->with($this->identicalTo(['data' => '{"body":"","properties":[],"headers":{"key1":"value1"}}', 'attributes' => ['key1' => 'value1']])) + ; + + $client = $this->createPubSubClientMock(); + $client + ->expects($this->once()) + ->method('topic') + ->with('topic-name') + ->willReturn($gtopic) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getClient') + ->willReturn($client) + ; + + $producer = new GpsProducer($context); + $producer->send($topic, $message); + } + + /** + * @return GpsContext|\PHPUnit\Framework\MockObject\MockObject|GpsContext + */ + private function createContextMock() + { + return $this->createMock(GpsContext::class); + } + + /** + * @return PubSubClient|\PHPUnit\Framework\MockObject\MockObject|PubSubClient + */ + private function createPubSubClientMock() + { + return $this->createMock(PubSubClient::class); + } + + /** + * @return Topic|\PHPUnit\Framework\MockObject\MockObject|Topic + */ + private function createGTopicMock() + { + return $this->createMock(Topic::class); + } +} diff --git a/pkg/gps/Tests/Spec/GpsConnectionFactoryTest.php b/pkg/gps/Tests/Spec/GpsConnectionFactoryTest.php new file mode 100644 index 000000000..3166d24e7 --- /dev/null +++ b/pkg/gps/Tests/Spec/GpsConnectionFactoryTest.php @@ -0,0 +1,14 @@ +createMock(PubSubClient::class)); + } +} diff --git a/pkg/gps/Tests/Spec/GpsMessageTest.php b/pkg/gps/Tests/Spec/GpsMessageTest.php new file mode 100644 index 000000000..7d7fe7191 --- /dev/null +++ b/pkg/gps/Tests/Spec/GpsMessageTest.php @@ -0,0 +1,14 @@ +createMock(GpsContext::class)); + } +} diff --git a/pkg/gps/Tests/Spec/GpsQueueTest.php b/pkg/gps/Tests/Spec/GpsQueueTest.php new file mode 100644 index 000000000..f4e401f6d --- /dev/null +++ b/pkg/gps/Tests/Spec/GpsQueueTest.php @@ -0,0 +1,14 @@ +buildGpsContext(); + } + + /** + * @param GpsContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->subscribe($this->topic, $queue); + + return $queue; + } + + protected function createTopic(Context $context, $topicName) + { + return $this->topic = parent::createTopic($context, $topicName); + } +} diff --git a/pkg/gps/Tests/Spec/GpsSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/gps/Tests/Spec/GpsSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..3a96bb533 --- /dev/null +++ b/pkg/gps/Tests/Spec/GpsSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,40 @@ +buildGpsContext(); + } + + /** + * @param GpsContext $context + */ + protected function createQueue(Context $context, $queueName) + { + $queue = parent::createQueue($context, $queueName); + + $context->subscribe($this->topic, $queue); + + return $queue; + } + + protected function createTopic(Context $context, $topicName) + { + return $this->topic = parent::createTopic($context, $topicName); + } +} diff --git a/pkg/gps/Tests/Spec/GpsTopicTest.php b/pkg/gps/Tests/Spec/GpsTopicTest.php new file mode 100644 index 000000000..3cbb95ad0 --- /dev/null +++ b/pkg/gps/Tests/Spec/GpsTopicTest.php @@ -0,0 +1,14 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/job-queue/.gitattributes b/pkg/job-queue/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/job-queue/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/job-queue/.github/workflows/ci.yml b/pkg/job-queue/.github/workflows/ci.yml new file mode 100644 index 000000000..28a9a9c02 --- /dev/null +++ b/pkg/job-queue/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-dist" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/job-queue/.travis.yml b/pkg/job-queue/.travis.yml deleted file mode 100644 index 42374ddc7..000000000 --- a/pkg/job-queue/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer self-update - - composer install --prefer-source - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/job-queue/CalculateRootJobStatusProcessor.php b/pkg/job-queue/CalculateRootJobStatusProcessor.php index 82cb8e897..96e7db2e8 100644 --- a/pkg/job-queue/CalculateRootJobStatusProcessor.php +++ b/pkg/job-queue/CalculateRootJobStatusProcessor.php @@ -2,16 +2,17 @@ namespace Enqueue\JobQueue; -use Enqueue\Client\MessageProducerInterface; -use Enqueue\Client\TopicSubscriberInterface; +use Enqueue\Client\CommandSubscriberInterface; +use Enqueue\Client\ProducerInterface; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\Util\JSON; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\Processor; use Psr\Log\LoggerInterface; -class CalculateRootJobStatusProcessor implements Processor, TopicSubscriberInterface +class CalculateRootJobStatusProcessor implements Processor, CommandSubscriberInterface { /** * @var JobStorage @@ -24,7 +25,7 @@ class CalculateRootJobStatusProcessor implements Processor, TopicSubscriberInter private $calculateRootJobStatusService; /** - * @var MessageProducerInterface + * @var ProducerInterface */ private $producer; @@ -33,17 +34,11 @@ class CalculateRootJobStatusProcessor implements Processor, TopicSubscriberInter */ private $logger; - /** - * @param JobStorage $jobStorage - * @param CalculateRootJobStatusService $calculateRootJobStatusCase - * @param MessageProducerInterface $producer - * @param LoggerInterface $logger - */ public function __construct( JobStorage $jobStorage, CalculateRootJobStatusService $calculateRootJobStatusCase, - MessageProducerInterface $producer, - LoggerInterface $logger + ProducerInterface $producer, + LoggerInterface $logger, ) { $this->jobStorage = $jobStorage; $this->calculateRootJobStatusService = $calculateRootJobStatusCase; @@ -51,9 +46,6 @@ public function __construct( $this->logger = $logger; } - /** - * {@inheritdoc} - */ public function process(Message $message, Context $context) { $data = JSON::decode($message->getBody()); @@ -74,7 +66,7 @@ public function process(Message $message, Context $context) $isRootJobStopped = $this->calculateRootJobStatusService->calculate($job); if ($isRootJobStopped) { - $this->producer->send(Topics::ROOT_JOB_STOPPED, [ + $this->producer->sendEvent(Topics::ROOT_JOB_STOPPED, [ 'jobId' => $job->getRootJob()->getId(), ]); } @@ -82,11 +74,8 @@ public function process(Message $message, Context $context) return Result::ACK; } - /** - * {@inheritdoc} - */ - public static function getSubscribedTopics() + public static function getSubscribedCommand() { - return [Topics::CALCULATE_ROOT_JOB_STATUS]; + return Commands::CALCULATE_ROOT_JOB_STATUS; } } diff --git a/pkg/job-queue/CalculateRootJobStatusService.php b/pkg/job-queue/CalculateRootJobStatusService.php index ae54315da..41dd350b9 100644 --- a/pkg/job-queue/CalculateRootJobStatusService.php +++ b/pkg/job-queue/CalculateRootJobStatusService.php @@ -3,6 +3,7 @@ namespace Enqueue\JobQueue; use Doctrine\Common\Collections\Collection; +use Enqueue\JobQueue\Doctrine\JobStorage; class CalculateRootJobStatusService { @@ -11,17 +12,12 @@ class CalculateRootJobStatusService */ private $jobStorage; - /** - * @param JobStorage $jobStorage - */ public function __construct(JobStorage $jobStorage) { $this->jobStorage = $jobStorage; } /** - * @param Job $job - * * @return bool true if root job was stopped */ public function calculate(Job $job) @@ -73,6 +69,7 @@ protected function calculateRootJobStatus(array $jobs) $success = 0; foreach ($jobs as $job) { + $this->jobStorage->refreshJobEntity($job); switch ($job->getStatus()) { case Job::STATUS_NEW: $new++; @@ -90,11 +87,7 @@ protected function calculateRootJobStatus(array $jobs) $success++; break; default: - throw new \LogicException(sprintf( - 'Got unsupported job status: id: "%s" status: "%s"', - $job->getId(), - $job->getStatus() - )); + throw new \LogicException(sprintf('Got unsupported job status: id: "%s" status: "%s"', $job->getId(), $job->getStatus())); } } diff --git a/pkg/job-queue/Commands.php b/pkg/job-queue/Commands.php new file mode 100644 index 000000000..ae744c991 --- /dev/null +++ b/pkg/job-queue/Commands.php @@ -0,0 +1,8 @@ +job = $job; diff --git a/pkg/job-queue/DependentJobProcessor.php b/pkg/job-queue/DependentJobProcessor.php index 02c0e8a35..ac3055b5f 100644 --- a/pkg/job-queue/DependentJobProcessor.php +++ b/pkg/job-queue/DependentJobProcessor.php @@ -2,14 +2,15 @@ namespace Enqueue\JobQueue; -use Enqueue\Client\Message; -use Enqueue\Client\MessageProducerInterface; +use Enqueue\Client\Message as ClientMessage; +use Enqueue\Client\ProducerInterface; use Enqueue\Client\TopicSubscriberInterface; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message as PsrMessage; -use Enqueue\Psr\Processor; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\Util\JSON; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\Processor; use Psr\Log\LoggerInterface; class DependentJobProcessor implements Processor, TopicSubscriberInterface @@ -20,7 +21,7 @@ class DependentJobProcessor implements Processor, TopicSubscriberInterface private $jobStorage; /** - * @var MessageProducerInterface + * @var ProducerInterface */ private $producer; @@ -29,22 +30,14 @@ class DependentJobProcessor implements Processor, TopicSubscriberInterface */ private $logger; - /** - * @param JobStorage $jobStorage - * @param MessageProducerInterface $producer - * @param LoggerInterface $logger - */ - public function __construct(JobStorage $jobStorage, MessageProducerInterface $producer, LoggerInterface $logger) + public function __construct(JobStorage $jobStorage, ProducerInterface $producer, LoggerInterface $logger) { $this->jobStorage = $jobStorage; $this->producer = $producer; $this->logger = $logger; } - /** - * {@inheritdoc} - */ - public function process(PsrMessage $message, Context $context) + public function process(Message $message, Context $context) { $data = JSON::decode($message->getBody()); @@ -97,24 +90,21 @@ public function process(PsrMessage $message, Context $context) } foreach ($dependentJobs as $dependentJob) { - $message = new Message(); + $message = new ClientMessage(); $message->setBody($dependentJob['message']); if (isset($dependentJob['priority'])) { $message->setPriority($dependentJob['priority']); } - $this->producer->send($dependentJob['topic'], $message); + $this->producer->sendEvent($dependentJob['topic'], $message); } return Result::ACK; } - /** - * {@inheritdoc} - */ public static function getSubscribedTopics() { - return [Topics::ROOT_JOB_STOPPED]; + return Topics::ROOT_JOB_STOPPED; } } diff --git a/pkg/job-queue/DependentJobService.php b/pkg/job-queue/DependentJobService.php index e0d42b961..9ab500ba8 100644 --- a/pkg/job-queue/DependentJobService.php +++ b/pkg/job-queue/DependentJobService.php @@ -2,6 +2,8 @@ namespace Enqueue\JobQueue; +use Enqueue\JobQueue\Doctrine\JobStorage; + class DependentJobService { /** @@ -18,8 +20,6 @@ public function __construct(JobStorage $jobStorage) } /** - * @param Job $job - * * @return DependentJobContext */ public function createDependentJobContext(Job $job) @@ -27,16 +27,10 @@ public function createDependentJobContext(Job $job) return new DependentJobContext($job); } - /** - * @param DependentJobContext $context - */ public function saveDependentJob(DependentJobContext $context) { if (!$context->getJob()->isRoot()) { - throw new \LogicException(sprintf( - 'Only root jobs allowed but got child. jobId: "%s"', - $context->getJob()->getId() - )); + throw new \LogicException(sprintf('Only root jobs allowed but got child. jobId: "%s"', $context->getJob()->getId())); } $this->jobStorage->saveJob($context->getJob(), function (Job $job) use ($context) { diff --git a/pkg/job-queue/Doctrine/Entity/Job.php b/pkg/job-queue/Doctrine/Entity/Job.php new file mode 100644 index 000000000..7a7a89a49 --- /dev/null +++ b/pkg/job-queue/Doctrine/Entity/Job.php @@ -0,0 +1,16 @@ +childJobs = new ArrayCollection(); + } +} diff --git a/pkg/job-queue/Doctrine/Entity/JobUnique.php b/pkg/job-queue/Doctrine/Entity/JobUnique.php new file mode 100644 index 000000000..d6fcd0600 --- /dev/null +++ b/pkg/job-queue/Doctrine/Entity/JobUnique.php @@ -0,0 +1,8 @@ +getEntityRepository()->createQueryBuilder('job'); - return $qb + $job = $qb ->addSelect('rootJob') ->leftJoin('job.rootJob', 'rootJob') ->where('job = :id') ->setParameter('id', $id) ->getQuery()->getOneOrNullResult() ; + if ($job) { + $this->refreshJobEntity($job); + } + + return $job; } /** @@ -88,7 +94,6 @@ public function findRootJobByOwnerIdAndJobName($ownerId, $jobName) /** * @param string $name - * @param Job $rootJob * * @return Job */ @@ -117,20 +122,13 @@ public function createJob() } /** - * @param Job $job - * @param \Closure|null $lockCallback - * * @throws DuplicateJobException */ - public function saveJob(Job $job, \Closure $lockCallback = null) + public function saveJob(Job $job, ?\Closure $lockCallback = null) { $class = $this->getEntityRepository()->getClassName(); if (!$job instanceof $class) { - throw new \LogicException(sprintf( - 'Got unexpected job instance: expected: "%s", actual" "%s"', - $class, - get_class($job) - )); + throw new \LogicException(sprintf('Got unexpected job instance: expected: "%s", actual" "%s"', $class, $job::class)); } if ($lockCallback) { @@ -173,11 +171,7 @@ public function saveJob(Job $job, \Closure $lockCallback = null) ]); } } catch (UniqueConstraintViolationException $e) { - throw new DuplicateJobException(sprintf( - 'Duplicate job. ownerId:"%s", name:"%s"', - $job->getOwnerId(), - $job->getName() - )); + throw new DuplicateJobException(sprintf('Duplicate job. ownerId:"%s", name:"%s"', $job->getOwnerId(), $job->getName())); } $this->getEntityManager()->persist($job); @@ -190,6 +184,14 @@ public function saveJob(Job $job, \Closure $lockCallback = null) } } + /** + * @param Job $job + */ + public function refreshJobEntity($job) + { + $this->getEntityManager()->refresh($job); + } + /** * @return EntityRepository */ @@ -210,6 +212,9 @@ private function getEntityManager() if (!$this->em) { $this->em = $this->doctrine->getManagerForClass($this->entityClass); } + if (!$this->em->isOpen()) { + $this->em = $this->doctrine->resetManager(); + } return $this->em; } diff --git a/pkg/job-queue/Doctrine/mapping/Job.orm.xml b/pkg/job-queue/Doctrine/mapping/Job.orm.xml new file mode 100644 index 000000000..d6f481562 --- /dev/null +++ b/pkg/job-queue/Doctrine/mapping/Job.orm.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/job-queue/Doctrine/mapping/JobUnique.orm.xml b/pkg/job-queue/Doctrine/mapping/JobUnique.orm.xml new file mode 100644 index 000000000..d9bfb2558 --- /dev/null +++ b/pkg/job-queue/Doctrine/mapping/JobUnique.orm.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/job-queue/Job.php b/pkg/job-queue/Job.php index 96e1b6432..ddf53c2e3 100644 --- a/pkg/job-queue/Job.php +++ b/pkg/job-queue/Job.php @@ -4,11 +4,11 @@ class Job { - const STATUS_NEW = 'enqueue.job_queue.status.new'; - const STATUS_RUNNING = 'enqueue.job_queue.status.running'; - const STATUS_SUCCESS = 'enqueue.job_queue.status.success'; - const STATUS_FAILED = 'enqueue.job_queue.status.failed'; - const STATUS_CANCELLED = 'enqueue.job_queue.status.cancelled'; + public const STATUS_NEW = 'enqueue.job_queue.status.new'; + public const STATUS_RUNNING = 'enqueue.job_queue.status.running'; + public const STATUS_SUCCESS = 'enqueue.job_queue.status.success'; + public const STATUS_FAILED = 'enqueue.job_queue.status.failed'; + public const STATUS_CANCELLED = 'enqueue.job_queue.status.cancelled'; /** * @var int @@ -216,10 +216,8 @@ public function getRootJob() * Do not call from the outside. * * @internal - * - * @param Job $rootJob */ - public function setRootJob(Job $rootJob) + public function setRootJob(self $rootJob) { $this->rootJob = $rootJob; } @@ -237,8 +235,6 @@ public function getCreatedAt() * Do not call from the outside. * * @internal - * - * @param \DateTime $createdAt */ public function setCreatedAt(\DateTime $createdAt) { @@ -258,8 +254,6 @@ public function getStartedAt() * Do not call from the outside. * * @internal - * - * @param \DateTime $startedAt */ public function setStartedAt(\DateTime $startedAt) { @@ -279,8 +273,6 @@ public function getStoppedAt() * Do not call from the outside. * * @internal - * - * @param \DateTime $stoppedAt */ public function setStoppedAt(\DateTime $stoppedAt) { @@ -324,9 +316,6 @@ public function getData() return $this->data; } - /** - * @param array $data - */ public function setData(array $data) { $this->data = $data; diff --git a/pkg/job-queue/JobProcessor.php b/pkg/job-queue/JobProcessor.php index c2b0f3b83..06698f45c 100644 --- a/pkg/job-queue/JobProcessor.php +++ b/pkg/job-queue/JobProcessor.php @@ -2,7 +2,8 @@ namespace Enqueue\JobQueue; -use Enqueue\Client\MessageProducerInterface; +use Enqueue\Client\ProducerInterface; +use Enqueue\JobQueue\Doctrine\JobStorage; class JobProcessor { @@ -12,15 +13,11 @@ class JobProcessor private $jobStorage; /** - * @var MessageProducerInterface + * @var ProducerInterface */ private $producer; - /** - * @param JobStorage $jobStorage - * @param MessageProducerInterface $producer - */ - public function __construct(JobStorage $jobStorage, MessageProducerInterface $producer) + public function __construct(JobStorage $jobStorage, ProducerInterface $producer) { $this->jobStorage = $jobStorage; $this->producer = $producer; @@ -62,7 +59,7 @@ public function findOrCreateRootJob($ownerId, $jobName, $unique = false) $job->setUnique((bool) $unique); try { - $this->jobStorage->saveJob($job); + $this->saveJob($job); return $job; } catch (DuplicateJobException $e) { @@ -73,7 +70,6 @@ public function findOrCreateRootJob($ownerId, $jobName, $unique = false) /** * @param string $jobName - * @param Job $rootJob * * @return Job */ @@ -96,18 +92,13 @@ public function findOrCreateChildJob($jobName, Job $rootJob) $job->setCreatedAt(new \DateTime()); $job->setRootJob($rootJob); - $this->jobStorage->saveJob($job); + $this->saveJob($job); - $this->producer->send(Topics::CALCULATE_ROOT_JOB_STATUS, [ - 'jobId' => $job->getId(), - ]); + $this->sendCalculateRootJobStatusEvent($job); return $job; } - /** - * @param Job $job - */ public function startChildJob(Job $job) { if ($job->isRoot()) { @@ -116,27 +107,18 @@ public function startChildJob(Job $job) $job = $this->jobStorage->findJobById($job->getId()); - if ($job->getStatus() !== Job::STATUS_NEW) { - throw new \LogicException(sprintf( - 'Can start only new jobs: id: "%s", status: "%s"', - $job->getId(), - $job->getStatus() - )); + if (Job::STATUS_NEW !== $job->getStatus()) { + throw new \LogicException(sprintf('Can start only new jobs: id: "%s", status: "%s"', $job->getId(), $job->getStatus())); } $job->setStatus(Job::STATUS_RUNNING); $job->setStartedAt(new \DateTime()); - $this->jobStorage->saveJob($job); + $this->saveJob($job); - $this->producer->send(Topics::CALCULATE_ROOT_JOB_STATUS, [ - 'jobId' => $job->getId(), - ]); + $this->sendCalculateRootJobStatusEvent($job); } - /** - * @param Job $job - */ public function successChildJob(Job $job) { if ($job->isRoot()) { @@ -145,27 +127,18 @@ public function successChildJob(Job $job) $job = $this->jobStorage->findJobById($job->getId()); - if ($job->getStatus() !== Job::STATUS_RUNNING) { - throw new \LogicException(sprintf( - 'Can success only running jobs. id: "%s", status: "%s"', - $job->getId(), - $job->getStatus() - )); + if (Job::STATUS_RUNNING !== $job->getStatus()) { + throw new \LogicException(sprintf('Can success only running jobs. id: "%s", status: "%s"', $job->getId(), $job->getStatus())); } $job->setStatus(Job::STATUS_SUCCESS); $job->setStoppedAt(new \DateTime()); - $this->jobStorage->saveJob($job); + $this->saveJob($job); - $this->producer->send(Topics::CALCULATE_ROOT_JOB_STATUS, [ - 'jobId' => $job->getId(), - ]); + $this->sendCalculateRootJobStatusEvent($job); } - /** - * @param Job $job - */ public function failChildJob(Job $job) { if ($job->isRoot()) { @@ -174,27 +147,18 @@ public function failChildJob(Job $job) $job = $this->jobStorage->findJobById($job->getId()); - if ($job->getStatus() !== Job::STATUS_RUNNING) { - throw new \LogicException(sprintf( - 'Can fail only running jobs. id: "%s", status: "%s"', - $job->getId(), - $job->getStatus() - )); + if (Job::STATUS_RUNNING !== $job->getStatus()) { + throw new \LogicException(sprintf('Can fail only running jobs. id: "%s", status: "%s"', $job->getId(), $job->getStatus())); } $job->setStatus(Job::STATUS_FAILED); $job->setStoppedAt(new \DateTime()); - $this->jobStorage->saveJob($job); + $this->saveJob($job); - $this->producer->send(Topics::CALCULATE_ROOT_JOB_STATUS, [ - 'jobId' => $job->getId(), - ]); + $this->sendCalculateRootJobStatusEvent($job); } - /** - * @param Job $job - */ public function cancelChildJob(Job $job) { if ($job->isRoot()) { @@ -204,11 +168,7 @@ public function cancelChildJob(Job $job) $job = $this->jobStorage->findJobById($job->getId()); if (!in_array($job->getStatus(), [Job::STATUS_NEW, Job::STATUS_RUNNING], true)) { - throw new \LogicException(sprintf( - 'Can cancel only new or running jobs. id: "%s", status: "%s"', - $job->getId(), - $job->getStatus() - )); + throw new \LogicException(sprintf('Can cancel only new or running jobs. id: "%s", status: "%s"', $job->getId(), $job->getStatus())); } $job->setStatus(Job::STATUS_CANCELLED); @@ -218,15 +178,12 @@ public function cancelChildJob(Job $job) $job->setStartedAt($stoppedAt); } - $this->jobStorage->saveJob($job); + $this->saveJob($job); - $this->producer->send(Topics::CALCULATE_ROOT_JOB_STATUS, [ - 'jobId' => $job->getId(), - ]); + $this->sendCalculateRootJobStatusEvent($job); } /** - * @param Job $job * @param bool $force */ public function interruptRootJob(Job $job, $force = false) @@ -251,4 +208,22 @@ public function interruptRootJob(Job $job, $force = false) } }); } + + /** + * @see https://github.com/php-enqueue/enqueue-dev/pull/222#issuecomment-336102749 See for rationale + */ + protected function saveJob(Job $job) + { + $this->jobStorage->saveJob($job); + } + + /** + * @see https://github.com/php-enqueue/enqueue-dev/pull/222#issuecomment-336102749 See for rationale + */ + protected function sendCalculateRootJobStatusEvent(Job $job) + { + $this->producer->sendCommand(Commands::CALCULATE_ROOT_JOB_STATUS, [ + 'jobId' => $job->getId(), + ]); + } } diff --git a/pkg/job-queue/JobRunner.php b/pkg/job-queue/JobRunner.php index bfca429a6..e21e524d6 100644 --- a/pkg/job-queue/JobRunner.php +++ b/pkg/job-queue/JobRunner.php @@ -14,24 +14,19 @@ class JobRunner */ private $rootJob; - /** - * @param JobProcessor $jobProcessor - * @param Job $rootJob - */ - public function __construct(JobProcessor $jobProcessor, Job $rootJob = null) + public function __construct(JobProcessor $jobProcessor, ?Job $rootJob = null) { $this->jobProcessor = $jobProcessor; $this->rootJob = $rootJob; } /** - * @param string $ownerId - * @param string $name - * @param \Closure $runCallback + * @param string $ownerId + * @param string $name * - * @return mixed + * @throws \Throwable|\Exception if $runCallback triggers an exception */ - public function runUnique($ownerId, $name, \Closure $runCallback) + public function runUnique($ownerId, $name, callable $runCallback) { $rootJob = $this->jobProcessor->findOrCreateRootJob($ownerId, $name, true); if (!$rootJob) { @@ -46,7 +41,17 @@ public function runUnique($ownerId, $name, \Closure $runCallback) $jobRunner = new self($this->jobProcessor, $rootJob); - $result = call_user_func($runCallback, $jobRunner, $childJob); + try { + $result = call_user_func($runCallback, $jobRunner, $childJob); + } catch (\Throwable $e) { + try { + $this->jobProcessor->failChildJob($childJob); + } catch (\Throwable $t) { + throw new OrphanJobException(sprintf('Job cleanup failed. ID: "%s" Name: "%s"', $childJob->getId(), $childJob->getName()), 0, $e); + } + + throw $e; + } if (!$childJob->getStoppedAt()) { $result @@ -58,12 +63,9 @@ public function runUnique($ownerId, $name, \Closure $runCallback) } /** - * @param string $name - * @param \Closure $startCallback - * - * @return mixed + * @param string $name */ - public function createDelayed($name, \Closure $startCallback) + public function createDelayed($name, callable $startCallback) { $childJob = $this->jobProcessor->findOrCreateChildJob($name, $this->rootJob); @@ -73,12 +75,9 @@ public function createDelayed($name, \Closure $startCallback) } /** - * @param string $jobId - * @param \Closure $runCallback - * - * @return mixed + * @param string $jobId */ - public function runDelayed($jobId, \Closure $runCallback) + public function runDelayed($jobId, callable $runCallback) { $job = $this->jobProcessor->findJobById($jobId); if (!$job) { diff --git a/pkg/job-queue/OrphanJobException.php b/pkg/job-queue/OrphanJobException.php new file mode 100644 index 000000000..37eac5ca7 --- /dev/null +++ b/pkg/job-queue/OrphanJobException.php @@ -0,0 +1,7 @@ +Supporting Enqueue + +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + # Job Queue. [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/job-queue.png?branch=master)](https://travis-ci.org/php-enqueue/job-queue) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/job-queue/ci.yml?branch=master)](https://github.com/php-enqueue/job-queue/actions?query=workflow%3ACI) [![Total Downloads](https://poser.pugx.org/enqueue/job-queue/d/total.png)](https://packagist.org/packages/enqueue/job-queue) [![Latest Stable Version](https://poser.pugx.org/enqueue/job-queue/version.png)](https://packagist.org/packages/enqueue/job-queue) - -There is job queue component build on top of a transport. -It provides some additional features like: unique job, sub jobs, dependent job and so. + +There is job queue component build on top of a transport. +It provides some additional features like: unique job, sub jobs, dependent job and so. Read more about it in documentation ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/job-queue/Schema.php b/pkg/job-queue/Schema.php deleted file mode 100644 index e5fb220a9..000000000 --- a/pkg/job-queue/Schema.php +++ /dev/null @@ -1,52 +0,0 @@ -uniqueTableName = $uniqueTableName; - - $schemaConfig = $connection->getSchemaManager()->createSchemaConfig(); - - parent::__construct([], [], $schemaConfig); - - $this->addUniqueJobTable(); - } - - /** - * Merges ACL schema with the given schema. - * - * @param BaseSchema $schema - */ - public function addToSchema(BaseSchema $schema) - { - foreach ($this->getTables() as $table) { - $schema->_addTable($table); - } - - foreach ($this->getSequences() as $sequence) { - $schema->_addSequence($sequence); - } - } - - private function addUniqueJobTable() - { - $table = $this->createTable($this->uniqueTableName); - $table->addColumn('name', 'string', ['length' => 255]); - $table->addUniqueIndex(['name']); - } -} diff --git a/pkg/job-queue/Test/DbalPersistedConnection.php b/pkg/job-queue/Test/DbalPersistedConnection.php index 7c06f5ab0..470a65176 100644 --- a/pkg/job-queue/Test/DbalPersistedConnection.php +++ b/pkg/job-queue/Test/DbalPersistedConnection.php @@ -22,9 +22,6 @@ class DbalPersistedConnection extends Connection */ protected static $persistedTransactionNestingLevels; - /** - * {@inheritdoc} - */ public function connect() { if ($this->isConnected()) { @@ -33,7 +30,6 @@ public function connect() if ($this->hasPersistedConnection()) { $this->_conn = $this->getPersistedConnection(); - $this->setConnected(true); } else { parent::connect(); $this->persistConnection($this->_conn); @@ -42,39 +38,25 @@ public function connect() return true; } - /** - * {@inheritdoc} - */ public function beginTransaction() { $this->wrapTransactionNestingLevel('beginTransaction'); + + return true; } - /** - * {@inheritdoc} - */ public function commit() { $this->wrapTransactionNestingLevel('commit'); + + return true; } - /** - * {@inheritdoc} - */ public function rollBack() { $this->wrapTransactionNestingLevel('rollBack'); - } - /** - * @param bool $connected - */ - protected function setConnected($connected) - { - $isConnected = new \ReflectionProperty('Doctrine\DBAL\Connection', '_isConnected'); - $isConnected->setAccessible(true); - $isConnected->setValue($this, $connected); - $isConnected->setAccessible(false); + return true; } /** @@ -97,9 +79,6 @@ protected function persistTransactionNestingLevel($level) static::$persistedTransactionNestingLevels[$this->getConnectionId()] = $level; } - /** - * @param DriverConnection $connection - */ protected function persistConnection(DriverConnection $connection) { static::$persistedConnections[$this->getConnectionId()] = $connection; @@ -134,10 +113,15 @@ protected function getConnectionId() */ private function setTransactionNestingLevel($level) { - $prop = new \ReflectionProperty('Doctrine\DBAL\Connection', '_transactionNestingLevel'); - $prop->setAccessible(true); - - return $prop->setValue($this, $level); + $rc = new \ReflectionClass(Connection::class); + $rp = $rc->hasProperty('transactionNestingLevel') ? + $rc->getProperty('transactionNestingLevel') : + $rc->getProperty('_transactionNestingLevel') + ; + + $rp->setAccessible(true); + $rp->setValue($this, $level); + $rp->setAccessible(false); } /** diff --git a/pkg/job-queue/Test/JobRunner.php b/pkg/job-queue/Test/JobRunner.php index 62d9f9a52..6194fbcee 100644 --- a/pkg/job-queue/Test/JobRunner.php +++ b/pkg/job-queue/Test/JobRunner.php @@ -26,9 +26,6 @@ public function __construct() { } - /** - * {@inheritdoc} - */ public function runUnique($ownerId, $jobName, \Closure $runCallback) { $this->runUniqueJobs[] = ['ownerId' => $ownerId, 'jobName' => $jobName, 'runCallback' => $runCallback]; @@ -36,11 +33,6 @@ public function runUnique($ownerId, $jobName, \Closure $runCallback) return call_user_func($runCallback, $this, new Job()); } - /** - * {@inheritdoc} - * - * @return mixed - */ public function createDelayed($jobName, \Closure $startCallback) { $this->createDelayedJobs[] = ['jobName' => $jobName, 'runCallback' => $startCallback]; @@ -48,11 +40,6 @@ public function createDelayed($jobName, \Closure $startCallback) return call_user_func($startCallback, $this, new Job()); } - /** - * {@inheritdoc} - * - * @return mixed - */ public function runDelayed($jobId, \Closure $runCallback) { $this->runDelayedJobs[] = ['jobId' => $jobId, 'runCallback' => $runCallback]; diff --git a/pkg/job-queue/Tests/CalculateRootJobStatusProcessorTest.php b/pkg/job-queue/Tests/CalculateRootJobStatusProcessorTest.php index 2a3c20736..8509f0544 100644 --- a/pkg/job-queue/Tests/CalculateRootJobStatusProcessorTest.php +++ b/pkg/job-queue/Tests/CalculateRootJobStatusProcessorTest.php @@ -2,34 +2,26 @@ namespace Enqueue\JobQueue\Tests; -use Enqueue\Client\MessageProducerInterface; +use Enqueue\Client\ProducerInterface; use Enqueue\Consumption\Result; use Enqueue\JobQueue\CalculateRootJobStatusProcessor; use Enqueue\JobQueue\CalculateRootJobStatusService; +use Enqueue\JobQueue\Commands; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\Job; -use Enqueue\JobQueue\JobStorage; use Enqueue\JobQueue\Topics; -use Enqueue\Psr\Context; -use Enqueue\Transport\Null\NullMessage; +use Enqueue\Null\NullMessage; +use Interop\Queue\Context; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -class CalculateRootJobStatusProcessorTest extends \PHPUnit_Framework_TestCase +class CalculateRootJobStatusProcessorTest extends \PHPUnit\Framework\TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new CalculateRootJobStatusProcessor( - $this->createJobStorageMock(), - $this->createCalculateRootJobStatusCaseMock(), - $this->createMessageProducerMock(), - $this->createLoggerMock() - ); - } - public function testShouldReturnSubscribedTopicNames() { $this->assertEquals( - [Topics::CALCULATE_ROOT_JOB_STATUS], - CalculateRootJobStatusProcessor::getSubscribedTopics() + Commands::CALCULATE_ROOT_JOB_STATUS, + CalculateRootJobStatusProcessor::getSubscribedCommand() ); } @@ -48,7 +40,7 @@ public function testShouldLogErrorAndRejectMessageIfMessageIsInvalid() $processor = new CalculateRootJobStatusProcessor( $this->createJobStorageMock(), $this->createCalculateRootJobStatusCaseMock(), - $this->createMessageProducerMock(), + $this->createProducerMock(), $logger ); $result = $processor->process($message, $this->createContextMock()); @@ -78,10 +70,10 @@ public function testShouldRejectMessageAndLogErrorIfJobWasNotFound() ->method('calculate') ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->never()) - ->method('send') + ->method('sendEvent') ; $message = new NullMessage(); @@ -102,7 +94,7 @@ public function testShouldCallCalculateJobRootStatusAndACKMessage() ->expects($this->once()) ->method('findJobById') ->with('12345') - ->will($this->returnValue($job)) + ->willReturn($job) ; $logger = $this->createLoggerMock(); @@ -118,10 +110,10 @@ public function testShouldCallCalculateJobRootStatusAndACKMessage() ->with($this->identicalTo($job)) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->never()) - ->method('send') + ->method('sendEvent') ; $message = new NullMessage(); @@ -145,7 +137,7 @@ public function testShouldSendRootJobStoppedMessageIfJobHasStopped() ->expects($this->once()) ->method('findJobById') ->with('12345') - ->will($this->returnValue($job)) + ->willReturn($job) ; $logger = $this->createLoggerMock(); @@ -155,13 +147,13 @@ public function testShouldSendRootJobStoppedMessageIfJobHasStopped() ->expects($this->once()) ->method('calculate') ->with($this->identicalTo($job)) - ->will($this->returnValue(true)) + ->willReturn(true) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendEvent') ->with(Topics::ROOT_JOB_STOPPED, ['jobId' => 12345]) ; @@ -175,15 +167,15 @@ public function testShouldSendRootJobStoppedMessageIfJobHasStopped() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducerInterface + * @return MockObject|ProducerInterface */ - private function createMessageProducerMock() + private function createProducerMock() { - return $this->createMock(MessageProducerInterface::class); + return $this->createMock(ProducerInterface::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context + * @return MockObject|Context */ private function createContextMock() { @@ -191,7 +183,7 @@ private function createContextMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface + * @return MockObject|LoggerInterface */ private function createLoggerMock() { @@ -199,7 +191,7 @@ private function createLoggerMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|CalculateRootJobStatusService + * @return MockObject|CalculateRootJobStatusService */ private function createCalculateRootJobStatusCaseMock() { @@ -207,7 +199,7 @@ private function createCalculateRootJobStatusCaseMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobStorage + * @return MockObject|JobStorage */ private function createJobStorageMock() { diff --git a/pkg/job-queue/Tests/CalculateRootJobStatusServiceTest.php b/pkg/job-queue/Tests/CalculateRootJobStatusServiceTest.php index a4c4b9b32..a0f1b4c86 100644 --- a/pkg/job-queue/Tests/CalculateRootJobStatusServiceTest.php +++ b/pkg/job-queue/Tests/CalculateRootJobStatusServiceTest.php @@ -3,16 +3,12 @@ namespace Enqueue\JobQueue\Tests; use Enqueue\JobQueue\CalculateRootJobStatusService; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\Job; -use Enqueue\JobQueue\JobStorage; +use PHPUnit\Framework\MockObject\MockObject; -class CalculateRootJobStatusServiceTest extends \PHPUnit_Framework_TestCase +class CalculateRootJobStatusServiceTest extends \PHPUnit\Framework\TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new CalculateRootJobStatusService($this->createJobStorageMock()); - } - public function stopStatusProvider() { return [ @@ -24,7 +20,6 @@ public function stopStatusProvider() /** * @dataProvider stopStatusProvider - * @param mixed $status */ public function testShouldDoNothingIfRootJobHasStopState($status) { @@ -59,9 +54,9 @@ public function testShouldCalculateRootJobStatus() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -73,7 +68,6 @@ public function testShouldCalculateRootJobStatus() /** * @dataProvider stopStatusProvider - * @param mixed $stopStatus */ public function testShouldCalculateRootJobStatusAndSetStoppedAtTimeIfGotStopStatus($stopStatus) { @@ -90,16 +84,19 @@ public function testShouldCalculateRootJobStatusAndSetStoppedAtTimeIfGotStopStat $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); $case->calculate($childJob); $this->assertEquals($stopStatus, $rootJob->getStatus()); - $this->assertEquals(new \DateTime(), $rootJob->getStoppedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $rootJob->getStoppedAt()->getTimestamp() + ); } public function testShouldSetStoppedAtOnlyIfWasNotSet() @@ -118,15 +115,18 @@ public function testShouldSetStoppedAtOnlyIfWasNotSet() $em ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($em); $case->calculate($childJob); - $this->assertEquals(new \DateTime('2012-12-12 12:12:12'), $rootJob->getStoppedAt()); + $this->assertEquals( + (new \DateTime('2012-12-12 12:12:12'))->getTimestamp(), + $rootJob->getStoppedAt()->getTimestamp() + ); } public function testShouldThrowIfInvalidStatus() @@ -144,17 +144,15 @@ public function testShouldThrowIfInvalidStatus() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); - $this->setExpectedException( - \LogicException::class, - 'Got unsupported job status: id: "12345" status: "invalid-status"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Got unsupported job status: id: "12345" status: "invalid-status"'); $case->calculate($childJob); } @@ -177,9 +175,9 @@ public function testShouldSetStatusNewIfAllChildAreNew() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -210,9 +208,9 @@ public function testShouldSetStatusRunningIfAnyOneIsRunning() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -243,9 +241,9 @@ public function testShouldSetStatusRunningIfThereIsNoRunningButNewAndAnyOfStopSt $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -276,9 +274,9 @@ public function testShouldSetStatusCancelledIfAllIsStopButOneIsCancelled() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -309,9 +307,9 @@ public function testShouldSetStatusFailedIfThereIsAnyOneIsFailedButIsNotCancelle $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -342,9 +340,9 @@ public function testShouldSetStatusSuccessIfAllAreSuccess() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; $case = new CalculateRootJobStatusService($storage); @@ -354,7 +352,7 @@ public function testShouldSetStatusSuccessIfAllAreSuccess() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobStorage + * @return MockObject|JobStorage */ private function createJobStorageMock() { diff --git a/pkg/job-queue/Tests/DependentJobContextTest.php b/pkg/job-queue/Tests/DependentJobContextTest.php index ec95ec9e0..35942a974 100644 --- a/pkg/job-queue/Tests/DependentJobContextTest.php +++ b/pkg/job-queue/Tests/DependentJobContextTest.php @@ -5,13 +5,8 @@ use Enqueue\JobQueue\DependentJobContext; use Enqueue\JobQueue\Job; -class DependentJobContextTest extends \PHPUnit_Framework_TestCase +class DependentJobContextTest extends \PHPUnit\Framework\TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new DependentJobContext(new Job()); - } - public function testShouldReturnJob() { $job = new Job(); diff --git a/pkg/job-queue/Tests/DependentJobProcessorTest.php b/pkg/job-queue/Tests/DependentJobProcessorTest.php index 7aaaf7d5f..dcebf0d49 100644 --- a/pkg/job-queue/Tests/DependentJobProcessorTest.php +++ b/pkg/job-queue/Tests/DependentJobProcessorTest.php @@ -3,22 +3,23 @@ namespace Enqueue\JobQueue\Tests; use Enqueue\Client\Message; -use Enqueue\Client\MessageProducerInterface; +use Enqueue\Client\ProducerInterface; use Enqueue\Consumption\Result; use Enqueue\JobQueue\DependentJobProcessor; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\Job; -use Enqueue\JobQueue\JobStorage; use Enqueue\JobQueue\Topics; -use Enqueue\Psr\Context; -use Enqueue\Transport\Null\NullMessage; +use Enqueue\Null\NullMessage; +use Interop\Queue\Context; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -class DependentJobProcessorTest extends \PHPUnit_Framework_TestCase +class DependentJobProcessorTest extends \PHPUnit\Framework\TestCase { public function testShouldReturnSubscribedTopicNames() { $this->assertEquals( - [Topics::ROOT_JOB_STOPPED], + Topics::ROOT_JOB_STOPPED, DependentJobProcessor::getSubscribedTopics() ); } @@ -27,7 +28,7 @@ public function testShouldLogCriticalAndRejectMessageIfJobIdIsNotSet() { $jobStorage = $this->createJobStorageMock(); - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $logger = $this->createLoggerMock(); $logger @@ -55,7 +56,7 @@ public function testShouldLogCriticalAndRejectMessageIfJobEntityWasNotFound() ->with(12345) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $logger = $this->createLoggerMock(); $logger @@ -84,10 +85,10 @@ public function testShouldLogCriticalAndRejectMessageIfJobIsNotRoot() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $logger = $this->createLoggerMock(); $logger @@ -115,13 +116,13 @@ public function testShouldDoNothingIfDependentJobsAreMissing() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->never()) - ->method('send') + ->method('sendEvent') ; $logger = $this->createLoggerMock(); @@ -151,13 +152,13 @@ public function testShouldLogCriticalAndRejectMessageIfDependentJobTopicIsMissin ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->never()) - ->method('send') + ->method('sendEvent') ; $logger = $this->createLoggerMock(); @@ -194,13 +195,13 @@ public function testShouldLogCriticalAndRejectMessageIfDependentJobMessageIsMiss ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->never()) - ->method('send') + ->method('sendEvent') ; $logger = $this->createLoggerMock(); @@ -239,18 +240,18 @@ public function testShouldPublishDependentMessage() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; $expectedMessage = null; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendEvent') ->with('topic-name', $this->isInstanceOf(Message::class)) - ->will($this->returnCallback(function ($topic, Message $message) use (&$expectedMessage) { + ->willReturnCallback(function ($topic, Message $message) use (&$expectedMessage) { $expectedMessage = $message; - })) + }) ; $logger = $this->createLoggerMock(); @@ -287,18 +288,18 @@ public function testShouldPublishDependentMessageWithPriority() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; $expectedMessage = null; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendEvent') ->with('topic-name', $this->isInstanceOf(Message::class)) - ->will($this->returnCallback(function ($topic, Message $message) use (&$expectedMessage) { + ->willReturnCallback(function ($topic, Message $message) use (&$expectedMessage) { $expectedMessage = $message; - })) + }) ; $logger = $this->createLoggerMock(); @@ -317,7 +318,7 @@ public function testShouldPublishDependentMessageWithPriority() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Context + * @return MockObject|Context */ private function createContextMock() { @@ -325,7 +326,7 @@ private function createContextMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobStorage + * @return MockObject|JobStorage */ private function createJobStorageMock() { @@ -333,15 +334,15 @@ private function createJobStorageMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducerInterface + * @return MockObject|ProducerInterface */ - private function createMessageProducerMock() + private function createProducerMock() { - return $this->createMock(MessageProducerInterface::class); + return $this->createMock(ProducerInterface::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface + * @return MockObject|LoggerInterface */ private function createLoggerMock() { diff --git a/pkg/job-queue/Tests/DependentJobServiceTest.php b/pkg/job-queue/Tests/DependentJobServiceTest.php index 581c463b6..c2a1c7b1a 100644 --- a/pkg/job-queue/Tests/DependentJobServiceTest.php +++ b/pkg/job-queue/Tests/DependentJobServiceTest.php @@ -4,16 +4,12 @@ use Enqueue\JobQueue\DependentJobContext; use Enqueue\JobQueue\DependentJobService; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\Job; -use Enqueue\JobQueue\JobStorage; +use PHPUnit\Framework\MockObject\MockObject; -class DependentJobServiceTest extends \PHPUnit_Framework_TestCase +class DependentJobServiceTest extends \PHPUnit\Framework\TestCase { - public function testCouldBeConstructedWithRequiredArguments() - { - new DependentJobService($this->createJobStorageMock()); - } - public function testShouldThrowIfJobIsNotRootJob() { $job = new Job(); @@ -24,7 +20,8 @@ public function testShouldThrowIfJobIsNotRootJob() $service = new DependentJobService($this->createJobStorageMock()); - $this->setExpectedException(\LogicException::class, 'Only root jobs allowed but got child. jobId: "12345"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Only root jobs allowed but got child. jobId: "12345"'); $service->saveDependentJob($context); } @@ -38,11 +35,11 @@ public function testShouldSaveDependentJobs() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); return true; - })) + }) ; $context = new DependentJobContext($job); @@ -66,7 +63,7 @@ public function testShouldSaveDependentJobs() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobStorage + * @return MockObject|JobStorage */ private function createJobStorageMock() { diff --git a/pkg/job-queue/Tests/Doctrine/JobStorageTest.php b/pkg/job-queue/Tests/Doctrine/JobStorageTest.php new file mode 100644 index 000000000..73f130d52 --- /dev/null +++ b/pkg/job-queue/Tests/Doctrine/JobStorageTest.php @@ -0,0 +1,611 @@ +createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $job = $storage->createJob(); + + $this->assertInstanceOf(Job::class, $job); + } + + public function testShouldResetManagerAndCreateJobObject() + { + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(false) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->any()) + ->method('resetManager') + ->willReturn($em) + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $job = $storage->createJob(); + + $this->assertInstanceOf(Job::class, $job); + } + + public function testShouldThrowIfGotUnexpectedJobInstance() + { + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn('expected\class\name') + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Got unexpected job instance: expected: "expected\class\name", actual" "Enqueue\JobQueue\Job"'); + + $storage->saveJob(new Job()); + } + + public function testShouldSaveJobWithoutLockIfThereIsNoCallbackAndChildJob() + { + $job = new Job(); + + $child = new Job(); + $child->setRootJob($job); + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->once()) + ->method('persist') + ->with($this->identicalTo($child)) + ; + $em + ->expects($this->once()) + ->method('flush') + ; + $em + ->expects($this->never()) + ->method('transactional') + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $storage->saveJob($child); + } + + public function testShouldSaveJobWithLockIfWithCallback() + { + $job = new Job(); + $job->setId(1234); + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->never()) + ->method('persist') + ->with($this->identicalTo($job)) + ; + $em + ->expects($this->never()) + ->method('flush') + ; + $em + ->expects($this->once()) + ->method('transactional') + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $storage->saveJob($job, function () { + }); + } + + public function testShouldCatchUniqueConstraintViolationExceptionAndThrowDuplicateJobException() + { + $job = new Job(); + $job->setOwnerId('owner-id'); + $job->setName('job-name'); + $job->setUnique(true); + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(function ($callback) use ($connection) { + $callback($connection); + }) + ; + $connection + ->expects($this->once()) + ->method('insert') + ->will($this->throwException($this->createUniqueConstraintViolationExceptionMock())) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->once()) + ->method('getConnection') + ->willReturn($connection) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + + $this->expectException(DuplicateJobException::class); + $this->expectExceptionMessage('Duplicate job. ownerId:"owner-id", name:"job-name"'); + + $storage->saveJob($job); + } + + public function testShouldThrowIfTryToSaveNewEntityWithLock() + { + $job = new Job(); + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Is not possible to create new job with lock, only update is allowed'); + $storage->saveJob($job, function () { + }); + } + + public function testShouldLockEntityAndPassNewInstanceIntoCallback() + { + $job = new Job(); + $job->setId(12345); + $lockedJob = new Job(); + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + $repository + ->expects($this->once()) + ->method('find') + ->with(12345, LockMode::PESSIMISTIC_WRITE) + ->willReturn($lockedJob) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(function ($callback) use ($em) { + $callback($em); + }) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $resultJob = null; + $storage->saveJob($job, function (Job $job) use (&$resultJob) { + $resultJob = $job; + }); + + $this->assertSame($lockedJob, $resultJob); + } + + public function testShouldInsertIntoUniqueTableIfJobIsUniqueAndNew() + { + $job = new Job(); + $job->setOwnerId('owner-id'); + $job->setName('job-name'); + $job->setUnique(true); + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(function ($callback) use ($connection) { + $callback($connection); + }) + ; + $connection + ->expects($this->at(0)) + ->method('insert') + ->with('unique_table', ['name' => 'owner-id']) + ; + $connection + ->expects($this->at(1)) + ->method('insert') + ->with('unique_table', ['name' => 'job-name']) + ; + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->once()) + ->method('getConnection') + ->willReturn($connection) + ; + $em + ->expects($this->once()) + ->method('persist') + ; + $em + ->expects($this->once()) + ->method('flush') + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $storage->saveJob($job); + } + + public function testShouldDeleteRecordFromUniqueTableIfJobIsUniqueAndStoppedAtIsSet() + { + $job = new Job(); + $job->setId(12345); + $job->setOwnerId('owner-id'); + $job->setName('job-name'); + $job->setUnique(true); + $job->setStoppedAt(new \DateTime()); + + $connection = $this->createConnectionMock(); + $connection + ->expects($this->at(0)) + ->method('delete') + ->with('unique_table', ['name' => 'owner-id']) + ; + $connection + ->expects($this->at(1)) + ->method('delete') + ->with('unique_table', ['name' => 'job-name']) + ; + + $repository = $this->createRepositoryMock(); + $repository + ->expects($this->once()) + ->method('getClassName') + ->willReturn(Job::class) + ; + $repository + ->expects($this->once()) + ->method('find') + ->willReturn($job) + ; + + $em = $this->createEntityManagerMock(); + $em + ->expects($this->once()) + ->method('getRepository') + ->with('entity-class') + ->willReturn($repository) + ; + $em + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(function ($callback) use ($em) { + $callback($em); + }) + ; + $em + ->expects($this->exactly(2)) + ->method('getConnection') + ->willReturn($connection) + ; + $em + ->expects($this->any()) + ->method('isOpen') + ->willReturn(true) + ; + + $doctrine = $this->createDoctrineMock(); + $doctrine + ->expects($this->once()) + ->method('getManagerForClass') + ->with('entity-class') + ->willReturn($em) + ; + $doctrine + ->expects($this->never()) + ->method('resetManager') + ; + + $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); + $storage->saveJob($job, function () { + }); + } + + /** + * @return MockObject|ManagerRegistry + */ + private function createDoctrineMock() + { + return $this->createMock(ManagerRegistry::class); + } + + /** + * @return MockObject|Connection + */ + private function createConnectionMock() + { + return $this->createMock(Connection::class); + } + + /** + * @return MockObject|EntityManager + */ + private function createEntityManagerMock() + { + return $this->createMock(EntityManager::class); + } + + /** + * @return MockObject|EntityRepository + */ + private function createRepositoryMock() + { + return $this->createMock(EntityRepository::class); + } + + /** + * @return MockObject|UniqueConstraintViolationException + */ + private function createUniqueConstraintViolationExceptionMock() + { + return $this->createMock(UniqueConstraintViolationException::class); + } +} diff --git a/pkg/job-queue/Tests/Functional/Entity/Job.php b/pkg/job-queue/Tests/Functional/Entity/Job.php index b6e0308a4..ad90e58a0 100644 --- a/pkg/job-queue/Tests/Functional/Entity/Job.php +++ b/pkg/job-queue/Tests/Functional/Entity/Job.php @@ -6,97 +6,47 @@ use Doctrine\ORM\Mapping as ORM; use Enqueue\JobQueue\Job as BaseJob; -/** - * @ORM\Entity - * @ORM\Table(name="enqueue_job_queue") - */ +#[ORM\Entity] +#[ORM\Table(name: 'enqueue_job_queue')] class Job extends BaseJob { - /** - * @var int - * - * @ORM\Column(name="id", type="integer") - * @ORM\Id - * @ORM\GeneratedValue(strategy="AUTO") - */ + #[ORM\Column(name: 'id', type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] protected $id; - /** - * @var string - * - * @ORM\Column(name="owner_id", type="string", nullable=true) - */ + #[ORM\Column(name: 'owner_id', type: 'string', nullable: true)] protected $ownerId; - /** - * @var string - * - * @ORM\Column(name="name", type="string", nullable=false) - */ + #[ORM\Column(name: 'name', type: 'string', nullable: false)] protected $name; - /** - * @var string - * - * @ORM\Column(name="status", type="string", nullable=false) - */ + #[ORM\Column(name: 'status', type: 'string', nullable: false)] protected $status; - /** - * @var bool - * - * @ORM\Column(name="interrupted", type="boolean") - */ + #[ORM\Column(name: 'interrupted', type: 'boolean')] protected $interrupted; - /** - * @var bool; - * - * @ORM\Column(name="`unique`", type="boolean") - */ + #[ORM\Column(name: '`unique`', type: 'boolean')] protected $unique; - /** - * @var Job - * - * @ORM\ManyToOne(targetEntity="Job", inversedBy="childJobs") - * @ORM\JoinColumn(name="root_job_id", referencedColumnName="id", onDelete="CASCADE") - */ + #[ORM\ManyToOne(targetEntity: 'Job', inversedBy: 'childJobs')] + #[ORM\JoinColumn(name: 'root_job_id', referencedColumnName: 'id', onDelete: 'CASCADE')] protected $rootJob; - /** - * @var Job[] - * - * @ORM\OneToMany(targetEntity="Job", mappedBy="rootJob") - */ + #[ORM\OneToMany(mappedBy: 'rootJob', targetEntity: 'Job')] protected $childJobs; - /** - * @var \DateTime - * - * @ORM\Column(name="created_at", type="datetime", nullable=false) - */ + #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] protected $createdAt; - /** - * @var \DateTime - * - * @ORM\Column(name="started_at", type="datetime", nullable=true) - */ + #[ORM\Column(name: 'started_at', type: 'datetime', nullable: true)] protected $startedAt; - /** - * @var \DateTime - * - * @ORM\Column(name="stopped_at", type="datetime", nullable=true) - */ + #[ORM\Column(name: 'stopped_at', type: 'datetime', nullable: true)] protected $stoppedAt; - /** - * @var array - * - * @ORM\Column(name="data", type="json_array", nullable=true) - */ + #[ORM\Column(name: 'data', type: 'json', nullable: true)] protected $data; public function __construct() diff --git a/pkg/job-queue/Tests/Functional/Entity/JobUnique.php b/pkg/job-queue/Tests/Functional/Entity/JobUnique.php index 4d8a33745..6d10ea2a5 100644 --- a/pkg/job-queue/Tests/Functional/Entity/JobUnique.php +++ b/pkg/job-queue/Tests/Functional/Entity/JobUnique.php @@ -4,15 +4,11 @@ use Doctrine\ORM\Mapping as ORM; -/** - * @ORM\Entity - * @ORM\Table(name="enqueue_job_queue_unique") - */ +#[ORM\Entity] +#[ORM\Table(name: 'enqueue_job_queue_unique')] class JobUnique { - /** - * @ORM\Id - * @ORM\Column(name="name", type="string", nullable=false) - */ + #[ORM\Id] + #[ORM\Column(name: 'name', type: 'string', nullable: false)] protected $name; } diff --git a/pkg/job-queue/Tests/Functional/Job/JobStorageTest.php b/pkg/job-queue/Tests/Functional/Job/JobStorageTest.php index 9c739fdff..e884c31e8 100644 --- a/pkg/job-queue/Tests/Functional/Job/JobStorageTest.php +++ b/pkg/job-queue/Tests/Functional/Job/JobStorageTest.php @@ -2,8 +2,8 @@ namespace Enqueue\JobQueue\Tests\Functional\Job; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\DuplicateJobException; -use Enqueue\JobQueue\JobStorage; use Enqueue\JobQueue\Tests\Functional\Entity\Job; use Enqueue\JobQueue\Tests\Functional\WebTestCase; @@ -128,7 +128,7 @@ public function testShouldThrowIfDuplicateJob() $job2->setStatus(Job::STATUS_NEW); $job2->setCreatedAt(new \DateTime()); - $this->setExpectedException(DuplicateJobException::class); + $this->expectException(DuplicateJobException::class); $this->getJobStorage()->saveJob($job2); } @@ -138,14 +138,14 @@ public function testShouldThrowIfDuplicateJob() */ private function getEntityManager() { - return $this->container->get('doctrine.orm.default_entity_manager'); + return static::$container->get('doctrine.orm.default_entity_manager'); } /** - * @return \Enqueue\JobQueue\JobStorage + * @return JobStorage */ private function getJobStorage() { - return new JobStorage($this->container->get('doctrine'), Job::class, 'enqueue_job_queue_unique'); + return new JobStorage(static::$container->get('doctrine'), Job::class, 'enqueue_job_queue_unique'); } } diff --git a/pkg/job-queue/Tests/Functional/WebTestCase.php b/pkg/job-queue/Tests/Functional/WebTestCase.php index da4913ee9..781bb5da4 100644 --- a/pkg/job-queue/Tests/Functional/WebTestCase.php +++ b/pkg/job-queue/Tests/Functional/WebTestCase.php @@ -11,36 +11,36 @@ abstract class WebTestCase extends BaseWebTestCase /** * @var Client */ - protected $client; + protected static $client; /** * @var ContainerInterface */ - protected $container; + protected static $container; - protected function setUp() + protected function setUp(): void { parent::setUp(); static::$class = null; - $this->client = static::createClient(); - $this->container = static::$kernel->getContainer(); + static::$client = static::createClient(); + + if (false == static::$container) { + static::$container = static::$kernel->getContainer(); + } $this->startTransaction(); } - protected function tearDown() + protected function tearDown(): void { $this->rollbackTransaction(); - parent::tearDown(); + static::ensureKernelShutdown(); } - /** - * @return string - */ - public static function getKernelClass() + public static function getKernelClass(): string { require_once __DIR__.'/app/AppKernel.php'; @@ -50,7 +50,7 @@ public static function getKernelClass() protected function startTransaction() { /** @var $em \Doctrine\ORM\EntityManager */ - foreach ($this->container->get('doctrine')->getManagers() as $em) { + foreach (static::$container->get('doctrine')->getManagers() as $em) { $em->clear(); $em->getConnection()->beginTransaction(); } @@ -58,15 +58,15 @@ protected function startTransaction() protected function rollbackTransaction() { - //the error can be thrown during setUp - //It would be caught by phpunit and tearDown called. - //In this case we could not rollback since container may not exist. - if (false == $this->container) { + // the error can be thrown during setUp + // It would be caught by phpunit and tearDown called. + // In this case we could not rollback since container may not exist. + if (false == static::$container) { return; } /** @var $em \Doctrine\ORM\EntityManager */ - foreach ($this->container->get('doctrine')->getManagers() as $em) { + foreach (static::$container->get('doctrine')->getManagers() as $em) { $connection = $em->getConnection(); while ($connection->isTransactionActive()) { diff --git a/pkg/job-queue/Tests/Functional/app/AppKernel.php b/pkg/job-queue/Tests/Functional/app/AppKernel.php index ce21df0b8..b51969f68 100644 --- a/pkg/job-queue/Tests/Functional/app/AppKernel.php +++ b/pkg/job-queue/Tests/Functional/app/AppKernel.php @@ -1,50 +1,42 @@ load(__DIR__.'/config/config-sf5.yml'); + + return; + } + $loader->load(__DIR__.'/config/config.yml'); } - protected function getContainerClass() + protected function getContainerClass(): string { return parent::getContainerClass().'JobQueue'; } diff --git a/pkg/job-queue/Tests/Functional/app/config/config-sf5.yml b/pkg/job-queue/Tests/Functional/app/config/config-sf5.yml new file mode 100644 index 000000000..dd3467e11 --- /dev/null +++ b/pkg/job-queue/Tests/Functional/app/config/config-sf5.yml @@ -0,0 +1,33 @@ +parameters: + locale: 'en' + secret: 'ThisTokenIsNotSoSecretChangeIt' + +framework: + #esi: ~ + #translator: { fallback: "%locale%" } + test: ~ + assets: false + session: + # the only option incompatible with Symfony 6 + storage_id: session.storage.mock_file + secret: "%secret%" + router: { resource: "%kernel.project_dir%/config/routing.yml" } + default_locale: "%locale%" + +doctrine: + dbal: + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql + charset: UTF8 + wrapper_class: "Enqueue\\JobQueue\\Test\\DbalPersistedConnection" + orm: + auto_generate_proxy_classes: true + auto_mapping: true + mappings: + TestEntity: + mapping: true + type: annotation + dir: '%kernel.project_dir%/Tests/Functional/Entity' + alias: 'EnqueueJobQueue' + prefix: 'Enqueue\JobQueue\Tests\Functional\Entity' + is_bundle: false diff --git a/pkg/job-queue/Tests/Functional/app/config/config.yml b/pkg/job-queue/Tests/Functional/app/config/config.yml index 122fdef96..0121acdbf 100644 --- a/pkg/job-queue/Tests/Functional/app/config/config.yml +++ b/pkg/job-queue/Tests/Functional/app/config/config.yml @@ -7,21 +7,16 @@ framework: #translator: { fallback: "%locale%" } test: ~ assets: false - templating: false session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file secret: "%secret%" - router: { resource: "%kernel.root_dir%/config/routing.yml" } + router: { resource: "%kernel.project_dir%/config/routing.yml" } default_locale: "%locale%" doctrine: dbal: - driver: "%db.driver%" - host: "%db.host%" - port: "%db.port%" - dbname: "%db.name%" - user: "%db.user%" - password: "%db.password%" + url: "%env(DOCTRINE_DSN)%" + driver: pdo_mysql charset: UTF8 wrapper_class: "Enqueue\\JobQueue\\Test\\DbalPersistedConnection" orm: @@ -30,8 +25,8 @@ doctrine: mappings: TestEntity: mapping: true - type: annotation - dir: '%kernel.root_dir%/../Entity' + type: attribute + dir: '%kernel.project_dir%/Tests/Functional/Entity' alias: 'EnqueueJobQueue' prefix: 'Enqueue\JobQueue\Tests\Functional\Entity' is_bundle: false diff --git a/pkg/job-queue/Tests/JobProcessorTest.php b/pkg/job-queue/Tests/JobProcessorTest.php index 31ce0a7d8..9f1c7b2fd 100644 --- a/pkg/job-queue/Tests/JobProcessorTest.php +++ b/pkg/job-queue/Tests/JobProcessorTest.php @@ -2,34 +2,33 @@ namespace Enqueue\JobQueue\Tests; -use Enqueue\Client\MessageProducer; +use Enqueue\Client\ProducerInterface; +use Enqueue\JobQueue\Commands; +use Enqueue\JobQueue\Doctrine\JobStorage; use Enqueue\JobQueue\DuplicateJobException; use Enqueue\JobQueue\Job; use Enqueue\JobQueue\JobProcessor; -use Enqueue\JobQueue\JobStorage; -use Enqueue\JobQueue\Topics; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; -class JobProcessorTest extends \PHPUnit_Framework_TestCase +class JobProcessorTest extends TestCase { - public function testCouldBeCreatedWithRequiredArguments() - { - new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); - } - public function testCreateRootJobShouldThrowIfOwnerIdIsEmpty() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); - $this->setExpectedException(\LogicException::class, 'OwnerId must not be empty'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('OwnerId must not be empty'); $processor->findOrCreateRootJob(null, 'job-name', true); } public function testCreateRootJobShouldThrowIfNameIsEmpty() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); - $this->setExpectedException(\LogicException::class, 'Job name must not be empty'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Job name must not be empty'); $processor->findOrCreateRootJob('owner-id', null, true); } @@ -42,7 +41,7 @@ public function testShouldCreateRootJobAndReturnIt() $storage ->expects($this->once()) ->method('createJob') - ->will($this->returnValue($job)) + ->willReturn($job) ; $storage ->expects($this->once()) @@ -50,14 +49,20 @@ public function testShouldCreateRootJobAndReturnIt() ->with($this->identicalTo($job)) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $result = $processor->findOrCreateRootJob('owner-id', 'job-name', true); $this->assertSame($job, $result); $this->assertEquals(Job::STATUS_NEW, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getCreatedAt(), '', 1); - $this->assertEquals(new \DateTime(), $job->getStartedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getCreatedAt()->getTimestamp() + ); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStartedAt()->getTimestamp() + ); $this->assertNull($job->getStoppedAt()); $this->assertEquals('job-name', $job->getName()); $this->assertEquals('owner-id', $job->getOwnerId()); @@ -71,7 +76,7 @@ public function testShouldCatchDuplicateJobAndTryToFindJobByOwnerId() $storage ->expects($this->once()) ->method('createJob') - ->will($this->returnValue($job)) + ->willReturn($job) ; $storage ->expects($this->once()) @@ -83,10 +88,10 @@ public function testShouldCatchDuplicateJobAndTryToFindJobByOwnerId() ->expects($this->once()) ->method('findRootJobByOwnerIdAndJobName') ->with('owner-id', 'job-name') - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $result = $processor->findOrCreateRootJob('owner-id', 'job-name', true); @@ -95,9 +100,10 @@ public function testShouldCatchDuplicateJobAndTryToFindJobByOwnerId() public function testCreateChildJobShouldThrowIfNameIsEmpty() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); - $this->setExpectedException(\LogicException::class, 'Job name must not be empty'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Job name must not be empty'); $processor->findOrCreateChildJob(null, new Job()); } @@ -120,16 +126,16 @@ public function testCreateChildJobShouldFindAndReturnAlreadyCreatedJob() ->expects($this->once()) ->method('findChildJobByName') ->with('job-name', $this->identicalTo($job)) - ->will($this->returnValue($job)) + ->willReturn($job) ; $storage ->expects($this->once()) ->method('findJobById') ->with(123) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $result = $processor->findOrCreateChildJob('job-name', $job); @@ -145,7 +151,7 @@ public function testCreateChildJobShouldCreateAndSaveJobAndPublishRecalculateRoo $storage ->expects($this->once()) ->method('createJob') - ->will($this->returnValue($job)) + ->willReturn($job) ; $storage ->expects($this->once()) @@ -156,20 +162,20 @@ public function testCreateChildJobShouldCreateAndSaveJobAndPublishRecalculateRoo ->expects($this->once()) ->method('findChildJobByName') ->with('job-name', $this->identicalTo($job)) - ->will($this->returnValue(null)) + ->willReturn(null) ; $storage ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') - ->with(Topics::CALCULATE_ROOT_JOB_STATUS, ['jobId' => 12345]) + ->method('sendCommand') + ->with(Commands::CALCULATE_ROOT_JOB_STATUS, ['jobId' => 12345]) ; $processor = new JobProcessor($storage, $producer); @@ -178,7 +184,10 @@ public function testCreateChildJobShouldCreateAndSaveJobAndPublishRecalculateRoo $this->assertSame($job, $result); $this->assertEquals(Job::STATUS_NEW, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getCreatedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getCreatedAt()->getTimestamp() + ); $this->assertNull($job->getStartedAt()); $this->assertNull($job->getStoppedAt()); $this->assertEquals('job-name', $job->getName()); @@ -187,12 +196,13 @@ public function testCreateChildJobShouldCreateAndSaveJobAndPublishRecalculateRoo public function testStartChildJobShouldThrowIfRootJob() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); $rootJob = new Job(); $rootJob->setId(12345); - $this->setExpectedException(\LogicException::class, 'Can\'t start root jobs. id: "12345"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can\'t start root jobs. id: "12345"'); $processor->startChildJob($rootJob); } @@ -209,15 +219,13 @@ public function testStartChildJobShouldThrowIfJobHasNotNewStatus() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); - $this->setExpectedException( - \LogicException::class, - 'Can start only new jobs: id: "12345", status: "enqueue.job_queue.status.cancelled"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can start only new jobs: id: "12345", status: "enqueue.job_queue.status.cancelled"'); $processor->startChildJob($job); } @@ -239,30 +247,34 @@ public function testStartJobShouldUpdateJobWithRunningStatusAndStartAtTime() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendCommand') ; $processor = new JobProcessor($storage, $producer); $processor->startChildJob($job); $this->assertEquals(Job::STATUS_RUNNING, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getStartedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStartedAt()->getTimestamp() + ); } public function testSuccessChildJobShouldThrowIfRootJob() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); $rootJob = new Job(); $rootJob->setId(12345); - $this->setExpectedException(\LogicException::class, 'Can\'t success root jobs. id: "12345"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can\'t success root jobs. id: "12345"'); $processor->successChildJob($rootJob); } @@ -279,15 +291,13 @@ public function testSuccessChildJobShouldThrowIfJobHasNotRunningStatus() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); - $this->setExpectedException( - \LogicException::class, - 'Can success only running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can success only running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"'); $processor->successChildJob($job); } @@ -309,30 +319,34 @@ public function testSuccessJobShouldUpdateJobWithSuccessStatusAndStopAtTime() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendCommand') ; $processor = new JobProcessor($storage, $producer); $processor->successChildJob($job); $this->assertEquals(Job::STATUS_SUCCESS, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getStoppedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStoppedAt()->getTimestamp() + ); } public function testFailChildJobShouldThrowIfRootJob() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); $rootJob = new Job(); $rootJob->setId(12345); - $this->setExpectedException(\LogicException::class, 'Can\'t fail root jobs. id: "12345"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can\'t fail root jobs. id: "12345"'); $processor->failChildJob($rootJob); } @@ -349,15 +363,13 @@ public function testFailChildJobShouldThrowIfJobHasNotRunningStatus() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); - $this->setExpectedException( - \LogicException::class, - 'Can fail only running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can fail only running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"'); $processor->failChildJob($job); } @@ -379,30 +391,34 @@ public function testFailJobShouldUpdateJobWithFailStatusAndStopAtTime() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendCommand') ; $processor = new JobProcessor($storage, $producer); $processor->failChildJob($job); $this->assertEquals(Job::STATUS_FAILED, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getStoppedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStoppedAt()->getTimestamp() + ); } public function testCancelChildJobShouldThrowIfRootJob() { - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); $rootJob = new Job(); $rootJob->setId(12345); - $this->setExpectedException(\LogicException::class, 'Can\'t cancel root jobs. id: "12345"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can\'t cancel root jobs. id: "12345"'); $processor->cancelChildJob($rootJob); } @@ -419,15 +435,13 @@ public function testCancelChildJobShouldThrowIfJobHasNotNewOrRunningStatus() ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); - $this->setExpectedException( - \LogicException::class, - 'Can cancel only new or running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"' - ); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can cancel only new or running jobs. id: "12345", status: "enqueue.job_queue.status.cancelled"'); $processor->cancelChildJob($job); } @@ -449,21 +463,27 @@ public function testCancelJobShouldUpdateJobWithCancelStatusAndStoppedAtTimeAndS ->expects($this->once()) ->method('findJobById') ->with(12345) - ->will($this->returnValue($job)) + ->willReturn($job) ; - $producer = $this->createMessageProducerMock(); + $producer = $this->createProducerMock(); $producer ->expects($this->once()) - ->method('send') + ->method('sendCommand') ; $processor = new JobProcessor($storage, $producer); $processor->cancelChildJob($job); $this->assertEquals(Job::STATUS_CANCELLED, $job->getStatus()); - $this->assertEquals(new \DateTime(), $job->getStoppedAt(), '', 1); - $this->assertEquals(new \DateTime(), $job->getStartedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStoppedAt()->getTimestamp() + ); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $job->getStartedAt()->getTimestamp() + ); } public function testInterruptRootJobShouldThrowIfNotRootJob() @@ -472,9 +492,10 @@ public function testInterruptRootJobShouldThrowIfNotRootJob() $notRootJob->setId(123); $notRootJob->setRootJob(new Job()); - $processor = new JobProcessor($this->createJobStorage(), $this->createMessageProducerMock()); + $processor = new JobProcessor($this->createJobStorage(), $this->createProducerMock()); - $this->setExpectedException(\LogicException::class, 'Can interrupt only root jobs. id: "123"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Can interrupt only root jobs. id: "123"'); $processor->interruptRootJob($notRootJob); } @@ -491,7 +512,7 @@ public function testInterruptRootJobShouldDoNothingIfAlreadyInterrupted() ->method('saveJob') ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $processor->interruptRootJob($rootJob); } @@ -504,12 +525,12 @@ public function testInterruptRootJobShouldUpdateJobAndSetInterruptedTrue() $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $processor->interruptRootJob($rootJob); $this->assertTrue($rootJob->isInterrupted()); @@ -525,31 +546,34 @@ public function testInterruptRootJobShouldUpdateJobAndSetInterruptedTrueAndStopp $storage ->expects($this->once()) ->method('saveJob') - ->will($this->returnCallback(function (Job $job, $callback) { + ->willReturnCallback(function (Job $job, $callback) { $callback($job); - })) + }) ; - $processor = new JobProcessor($storage, $this->createMessageProducerMock()); + $processor = new JobProcessor($storage, $this->createProducerMock()); $processor->interruptRootJob($rootJob, true); $this->assertTrue($rootJob->isInterrupted()); - $this->assertEquals(new \DateTime(), $rootJob->getStoppedAt(), '', 1); + $this->assertEquals( + (new \DateTime())->getTimestamp(), + $rootJob->getStoppedAt()->getTimestamp() + ); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobStorage + * @return MockObject */ - private function createJobStorage() + private function createJobStorage(): JobStorage { return $this->createMock(JobStorage::class); } /** - * @return \PHPUnit_Framework_MockObject_MockObject|MessageProducer + * @return MockObject */ - private function createMessageProducerMock() + private function createProducerMock(): ProducerInterface { - return $this->createMock(MessageProducer::class); + return $this->createMock(ProducerInterface::class); } } diff --git a/pkg/job-queue/Tests/JobRunnerTest.php b/pkg/job-queue/Tests/JobRunnerTest.php index 3d1eb3064..e43440fe3 100644 --- a/pkg/job-queue/Tests/JobRunnerTest.php +++ b/pkg/job-queue/Tests/JobRunnerTest.php @@ -5,8 +5,10 @@ use Enqueue\JobQueue\Job; use Enqueue\JobQueue\JobProcessor; use Enqueue\JobQueue\JobRunner; +use Enqueue\JobQueue\OrphanJobException; +use PHPUnit\Framework\MockObject\MockObject; -class JobRunnerTest extends \PHPUnit_Framework_TestCase +class JobRunnerTest extends \PHPUnit\Framework\TestCase { public function testRunUniqueShouldCreateRootAndChildJobAndCallCallback() { @@ -18,13 +20,13 @@ public function testRunUniqueShouldCreateRootAndChildJobAndCallCallback() ->expects($this->once()) ->method('findOrCreateRootJob') ->with('owner-id', 'job-name', true) - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') ->with('job-name') - ->will($this->returnValue($child)) + ->willReturn($child) ; $expChild = null; @@ -56,12 +58,12 @@ public function testRunUniqueShouldStartChildJobIfNotStarted() $jobProcessor ->expects($this->once()) ->method('findOrCreateRootJob') - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->once()) @@ -84,12 +86,12 @@ public function testRunUniqueShouldNotStartChildJobIfAlreadyStarted() $jobProcessor ->expects($this->once()) ->method('findOrCreateRootJob') - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->never()) @@ -110,12 +112,12 @@ public function testRunUniqueShouldSuccessJobIfCallbackReturnValueIsTrue() $jobProcessor ->expects($this->once()) ->method('findOrCreateRootJob') - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->once()) @@ -141,12 +143,12 @@ public function testRunUniqueShouldFailJobIfCallbackReturnValueIsFalse() $jobProcessor ->expects($this->once()) ->method('findOrCreateRootJob') - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->never()) @@ -163,6 +165,71 @@ public function testRunUniqueShouldFailJobIfCallbackReturnValueIsFalse() }); } + public function testRunUniqueShouldFailJobIfCallbackThrowsException() + { + $root = new Job(); + $child = new Job(); + + $jobProcessor = $this->createJobProcessorMock(); + $jobProcessor + ->expects($this->once()) + ->method('findOrCreateRootJob') + ->willReturn($root) + ; + $jobProcessor + ->expects($this->once()) + ->method('findOrCreateChildJob') + ->willReturn($child) + ; + $jobProcessor + ->expects($this->never()) + ->method('successChildJob') + ; + $jobProcessor + ->expects($this->once()) + ->method('failChildJob') + ; + + $jobRunner = new JobRunner($jobProcessor); + $this->expectException(\Exception::class); + $jobRunner->runUnique('owner-id', 'job-name', function () { + throw new \Exception(); + }); + } + + public function testRunUniqueShouldThrowOrphanJobExceptionIfChildCleanupFails() + { + $root = new Job(); + $child = new Job(); + + $jobProcessor = $this->createJobProcessorMock(); + $jobProcessor + ->expects($this->once()) + ->method('findOrCreateRootJob') + ->willReturn($root) + ; + $jobProcessor + ->expects($this->once()) + ->method('findOrCreateChildJob') + ->willReturn($child) + ; + $jobProcessor + ->expects($this->never()) + ->method('successChildJob') + ; + $jobProcessor + ->expects($this->once()) + ->method('failChildJob') + ->willThrowException(new \Exception()) + ; + + $jobRunner = new JobRunner($jobProcessor); + $this->expectException(OrphanJobException::class); + $jobRunner->runUnique('owner-id', 'job-name', function () { + throw new \Exception(); + }); + } + public function testRunUniqueShouldNotSuccessJobIfJobIsAlreadyStopped() { $root = new Job(); @@ -173,12 +240,12 @@ public function testRunUniqueShouldNotSuccessJobIfJobIsAlreadyStopped() $jobProcessor ->expects($this->once()) ->method('findOrCreateRootJob') - ->will($this->returnValue($root)) + ->willReturn($root) ; $jobProcessor ->expects($this->once()) ->method('findOrCreateChildJob') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->never()) @@ -205,7 +272,7 @@ public function testCreateDelayedShouldCreateChildJobAndCallCallback() ->expects($this->once()) ->method('findOrCreateChildJob') ->with('job-name', $this->identicalTo($root)) - ->will($this->returnValue($child)) + ->willReturn($child) ; $expRunner = null; @@ -230,12 +297,13 @@ public function testRunDelayedShouldThrowExceptionIfJobWasNotFoundById() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue(null)) + ->willReturn(null) ; $jobRunner = new JobRunner($jobProcessor); - $this->setExpectedException(\LogicException::class, 'Job was not found. id: "job-id"'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Job was not found. id: "job-id"'); $jobRunner->runDelayed('job-id', function () { }); @@ -252,7 +320,7 @@ public function testRunDelayedShouldFindJobAndCallCallback() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue($child)) + ->willReturn($child) ; $expRunner = null; @@ -282,7 +350,7 @@ public function testRunDelayedShouldCancelJobIfRootJobIsInterrupted() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->once()) @@ -307,7 +375,7 @@ public function testRunDelayedShouldSuccessJobIfCallbackReturnValueIsTrue() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->once()) @@ -336,7 +404,7 @@ public function testRunDelayedShouldFailJobIfCallbackReturnValueIsFalse() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->never()) @@ -366,7 +434,7 @@ public function testRunDelayedShouldNotSuccessJobIfAlreadyStopped() ->expects($this->once()) ->method('findJobById') ->with('job-id') - ->will($this->returnValue($child)) + ->willReturn($child) ; $jobProcessor ->expects($this->never()) @@ -384,7 +452,7 @@ public function testRunDelayedShouldNotSuccessJobIfAlreadyStopped() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|JobProcessor + * @return MockObject|JobProcessor */ private function createJobProcessorMock() { diff --git a/pkg/job-queue/Tests/JobStorageTest.php b/pkg/job-queue/Tests/JobStorageTest.php deleted file mode 100644 index 0ab3bfc84..000000000 --- a/pkg/job-queue/Tests/JobStorageTest.php +++ /dev/null @@ -1,498 +0,0 @@ -createDoctrineMock(), 'entity-class', 'unique_table'); - } - - public function testShouldCreateJobObject() - { - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $job = $storage->createJob(); - - $this->assertInstanceOf(Job::class, $job); - } - - public function testShouldThrowIfGotUnexpectedJobInstance() - { - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue('expected\class\name')) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - - $this->setExpectedException( - \LogicException::class, - 'Got unexpected job instance: expected: "expected\class\name", '. - 'actual" "Enqueue\JobQueue\Job"' - ); - - $storage->saveJob(new Job()); - } - - public function testShouldSaveJobWithoutLockIfThereIsNoCallbackAndChildJob() - { - $job = new Job(); - - $child = new Job(); - $child->setRootJob($job); - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->once()) - ->method('persist') - ->with($this->identicalTo($child)) - ; - $em - ->expects($this->once()) - ->method('flush') - ; - $em - ->expects($this->never()) - ->method('transactional') - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $storage->saveJob($child); - } - - public function testShouldSaveJobWithLockIfWithCallback() - { - $job = new Job(); - $job->setId(1234); - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->never()) - ->method('persist') - ->with($this->identicalTo($job)) - ; - $em - ->expects($this->never()) - ->method('flush') - ; - $em - ->expects($this->once()) - ->method('transactional') - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $storage->saveJob($job, function () { - }); - } - - public function testShouldCatchUniqueConstraintViolationExceptionAndThrowDuplicateJobException() - { - $job = new Job(); - $job->setOwnerId('owner-id'); - $job->setName('job-name'); - $job->setUnique(true); - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $connection = $this->createConnectionMock(); - $connection - ->expects($this->once()) - ->method('transactional') - ->will($this->returnCallback(function ($callback) use ($connection) { - $callback($connection); - })) - ; - $connection - ->expects($this->once()) - ->method('insert') - ->will($this->throwException($this->createUniqueConstraintViolationExceptionMock())) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->once()) - ->method('getConnection') - ->will($this->returnValue($connection)) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - - $this->setExpectedException(DuplicateJobException::class, 'Duplicate job. ownerId:"owner-id", name:"job-name"'); - - $storage->saveJob($job); - } - - public function testShouldThrowIfTryToSaveNewEntityWithLock() - { - $job = new Job(); - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - - $this->setExpectedException( - \LogicException::class, - 'Is not possible to create new job with lock, only update is allowed' - ); - - $storage->saveJob($job, function () { - }); - } - - public function testShouldLockEntityAndPassNewInstanceIntoCallback() - { - $job = new Job(); - $job->setId(12345); - $lockedJob = new Job(); - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - $repository - ->expects($this->once()) - ->method('find') - ->with(12345, LockMode::PESSIMISTIC_WRITE) - ->will($this->returnValue($lockedJob)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->once()) - ->method('transactional') - ->will($this->returnCallback(function ($callback) use ($em) { - $callback($em); - })) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $resultJob = null; - $storage->saveJob($job, function (Job $job) use (&$resultJob) { - $resultJob = $job; - }); - - $this->assertSame($lockedJob, $resultJob); - } - - public function testShouldInsertIntoUniqueTableIfJobIsUniqueAndNew() - { - $job = new Job(); - $job->setOwnerId('owner-id'); - $job->setName('job-name'); - $job->setUnique(true); - - $connection = $this->createConnectionMock(); - $connection - ->expects($this->once()) - ->method('transactional') - ->will($this->returnCallback(function ($callback) use ($connection) { - $callback($connection); - })) - ; - $connection - ->expects($this->at(0)) - ->method('insert') - ->with('unique_table', ['name' => 'owner-id']) - ; - $connection - ->expects($this->at(1)) - ->method('insert') - ->with('unique_table', ['name' => 'job-name']) - ; - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->once()) - ->method('getConnection') - ->will($this->returnValue($connection)) - ; - $em - ->expects($this->once()) - ->method('persist') - ; - $em - ->expects($this->once()) - ->method('flush') - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $storage->saveJob($job); - } - - public function testShouldDeleteRecordFromUniqueTableIfJobIsUniqueAndStoppedAtIsSet() - { - $job = new Job(); - $job->setId(12345); - $job->setOwnerId('owner-id'); - $job->setName('job-name'); - $job->setUnique(true); - $job->setStoppedAt(new \DateTime()); - - $connection = $this->createConnectionMock(); - $connection - ->expects($this->at(0)) - ->method('delete') - ->with('unique_table', ['name' => 'owner-id']) - ; - $connection - ->expects($this->at(1)) - ->method('delete') - ->with('unique_table', ['name' => 'job-name']) - ; - - $repository = $this->createRepositoryMock(); - $repository - ->expects($this->once()) - ->method('getClassName') - ->will($this->returnValue(Job::class)) - ; - $repository - ->expects($this->once()) - ->method('find') - ->will($this->returnValue($job)) - ; - - $em = $this->createEntityManagerMock(); - $em - ->expects($this->once()) - ->method('getRepository') - ->with('entity-class') - ->will($this->returnValue($repository)) - ; - $em - ->expects($this->once()) - ->method('transactional') - ->will($this->returnCallback(function ($callback) use ($em) { - $callback($em); - })) - ; - $em - ->expects($this->exactly(2)) - ->method('getConnection') - ->will($this->returnValue($connection)) - ; - - $doctrine = $this->createDoctrineMock(); - $doctrine - ->expects($this->once()) - ->method('getManagerForClass') - ->with('entity-class') - ->will($this->returnValue($em)) - ; - - $storage = new JobStorage($doctrine, 'entity-class', 'unique_table'); - $storage->saveJob($job, function () { - }); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ManagerRegistry - */ - private function createDoctrineMock() - { - return $this->createMock(ManagerRegistry::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Connection - */ - private function createConnectionMock() - { - return $this->createMock(Connection::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|EntityManager - */ - private function createEntityManagerMock() - { - return $this->createMock(EntityManager::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|EntityRepository - */ - private function createRepositoryMock() - { - return $this->createMock(EntityRepository::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|UniqueConstraintViolationException - */ - private function createUniqueConstraintViolationExceptionMock() - { - return $this->createMock(UniqueConstraintViolationException::class); - } -} diff --git a/pkg/job-queue/Topics.php b/pkg/job-queue/Topics.php index 664fa0b1c..891ea26f7 100644 --- a/pkg/job-queue/Topics.php +++ b/pkg/job-queue/Topics.php @@ -4,6 +4,5 @@ class Topics { - const CALCULATE_ROOT_JOB_STATUS = 'enqueue.message_queue.job.calculate_root_job_status'; - const ROOT_JOB_STOPPED = 'enqueue.message_queue.job.root_job_stopped'; + public const ROOT_JOB_STOPPED = 'enqueue.message_queue.job.root_job_stopped'; } diff --git a/pkg/job-queue/composer.json b/pkg/job-queue/composer.json index 116110bde..6616069b3 100644 --- a/pkg/job-queue/composer.json +++ b/pkg/job-queue/composer.json @@ -3,25 +3,31 @@ "type": "library", "description": "Job Queue", "keywords": ["messaging", "queue", "jobs"], + "homepage": "https://enqueue.forma-pro.com/", "license": "MIT", - "repositories": [ - { - "type": "vcs", - "url": "git@github.com:php-enqueue/test.git" - } - ], "require": { - "php": ">=5.6", - "symfony/framework-bundle": "^2.8|^3", - "enqueue/enqueue": "^0.2", - "doctrine/orm": "~2.4" + "php": "^8.1", + "enqueue/enqueue": "^0.10", + "enqueue/null": "^0.10", + "queue-interop/queue-interop": "^0.8", + "doctrine/orm": "^2.12", + "doctrine/dbal": "^2.12 | ^3.0" }, "require-dev": { - "phpunit/phpunit": "~5.5", - "enqueue/test": "^0.2", - "doctrine/doctrine-bundle": "~1.2", - "symfony/browser-kit": "^2.8|^3", - "symfony/expression-language": "^2.8|^3" + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "doctrine/doctrine-bundle": "^2.3.2", + "symfony/browser-kit": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" }, "autoload": { "psr-4": { "Enqueue\\JobQueue\\": "" }, @@ -32,7 +38,7 @@ "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } } } diff --git a/pkg/job-queue/phpunit.xml.dist b/pkg/job-queue/phpunit.xml.dist index 29dc33404..3665922c4 100644 --- a/pkg/job-queue/phpunit.xml.dist +++ b/pkg/job-queue/phpunit.xml.dist @@ -1,16 +1,11 @@ - + diff --git a/pkg/mongodb/.gitattributes b/pkg/mongodb/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/mongodb/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/mongodb/.github/workflows/ci.yml b/pkg/mongodb/.github/workflows/ci.yml new file mode 100644 index 000000000..415baf634 --- /dev/null +++ b/pkg/mongodb/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mongodb + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/mongodb/.gitignore b/pkg/mongodb/.gitignore new file mode 100644 index 000000000..57bbbe0bb --- /dev/null +++ b/pkg/mongodb/.gitignore @@ -0,0 +1,7 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ +/examples/ diff --git a/pkg/mongodb/JSON.php b/pkg/mongodb/JSON.php new file mode 100644 index 000000000..481b7f9ff --- /dev/null +++ b/pkg/mongodb/JSON.php @@ -0,0 +1,46 @@ + 'mongodb://127.0.0.1/' - Mongodb connection string. see http://docs.mongodb.org/manual/reference/connection-string/ + * 'dbname' => 'enqueue', - database name. + * 'collection_name' => 'enqueue' - collection name + * 'polling_interval' => '1000', - How often query for new messages (milliseconds) + * ] + * + * or + * + * mongodb://127.0.0.1:27017/defaultauthdb?polling_interval=1000&enqueue_database=enqueue&enqueue_collection=enqueue + * + * @param array|string|null $config + */ + public function __construct($config = 'mongodb:') + { + if (empty($config)) { + $config = $this->parseDsn('mongodb:'); + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + $config = array_replace( + $config, + $this->parseDsn(empty($config['dsn']) ? 'mongodb:' : $config['dsn']) + ); + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + $config = array_replace([ + 'dsn' => 'mongodb://127.0.0.1/', + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + ], $config); + + $this->config = $config; + } + + /** + * @return MongodbContext + */ + public function createContext(): Context + { + $client = new Client($this->config['dsn']); + + return new MongodbContext($client, $this->config); + } + + public static function parseDsn(string $dsn): array + { + $parsedUrl = parse_url($dsn); + if (false === $parsedUrl) { + throw new \LogicException(sprintf('Failed to parse DSN "%s"', $dsn)); + } + if (empty($parsedUrl['scheme'])) { + throw new \LogicException('Schema is empty'); + } + $supported = [ + 'mongodb' => true, + ]; + if (false == isset($parsedUrl['scheme'])) { + throw new \LogicException(sprintf('The given DSN schema "%s" is not supported. There are supported schemes: "%s".', $parsedUrl['scheme'], implode('", "', array_keys($supported)))); + } + if ('mongodb:' === $dsn) { + return [ + 'dsn' => 'mongodb://127.0.0.1/', + ]; + } + $config['dsn'] = $dsn; + // FIXME this is NOT a dbname but rather authdb. But removing this would be a BC break. + // see: https://github.com/php-enqueue/enqueue-dev/issues/1027 + if (isset($parsedUrl['path']) && '/' !== $parsedUrl['path']) { + $pathParts = explode('/', $parsedUrl['path']); + // DB name + if ($pathParts[1]) { + $config['dbname'] = $pathParts[1]; + } + } + if (isset($parsedUrl['query'])) { + $queryParts = null; + parse_str($parsedUrl['query'], $queryParts); + // get enqueue attributes values + if (!empty($queryParts['polling_interval'])) { + $config['polling_interval'] = (int) $queryParts['polling_interval']; + } + if (!empty($queryParts['enqueue_collection'])) { + $config['collection_name'] = $queryParts['enqueue_collection']; + } + if (!empty($queryParts['enqueue_database'])) { + $config['dbname'] = $queryParts['enqueue_database']; + } + } + + return $config; + } +} diff --git a/pkg/mongodb/MongodbConsumer.php b/pkg/mongodb/MongodbConsumer.php new file mode 100644 index 000000000..37ef12530 --- /dev/null +++ b/pkg/mongodb/MongodbConsumer.php @@ -0,0 +1,146 @@ +context = $context; + $this->queue = $queue; + + $this->pollingInterval = 1000; + } + + /** + * Set polling interval in milliseconds. + */ + public function setPollingInterval(int $msec): void + { + $this->pollingInterval = $msec; + } + + /** + * Get polling interval in milliseconds. + */ + public function getPollingInterval(): int + { + return $this->pollingInterval; + } + + /** + * @return MongodbDestination + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return MongodbMessage + */ + public function receive(int $timeout = 0): ?Message + { + $timeout /= 1000; + $startAt = microtime(true); + + while (true) { + $message = $this->receiveMessage(); + + if ($message) { + return $message; + } + + if ($timeout && (microtime(true) - $startAt) >= $timeout) { + return null; + } + + usleep($this->pollingInterval * 1000); + + if ($timeout && (microtime(true) - $startAt) >= $timeout) { + return null; + } + } + } + + /** + * @return MongodbMessage + */ + public function receiveNoWait(): ?Message + { + return $this->receiveMessage(); + } + + /** + * @param MongodbMessage $message + */ + public function acknowledge(Message $message): void + { + // does nothing + } + + /** + * @param MongodbMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, MongodbMessage::class); + + if ($requeue) { + $message->setRedelivered(true); + $this->context->createProducer()->send($this->queue, $message); + + return; + } + } + + private function receiveMessage(): ?MongodbMessage + { + $now = time(); + $collection = $this->context->getCollection(); + $message = $collection->findOneAndDelete( + [ + 'queue' => $this->queue->getName(), + '$or' => [ + ['delayed_until' => ['$exists' => false]], + ['delayed_until' => ['$lte' => $now]], + ], + ], + [ + 'sort' => ['priority' => -1, 'published_at' => 1], + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ] + ); + + if (!$message) { + return null; + } + if (empty($message['time_to_live']) || $message['time_to_live'] > time()) { + return $this->context->convertMessage($message); + } + + return null; + } +} diff --git a/pkg/mongodb/MongodbContext.php b/pkg/mongodb/MongodbContext.php new file mode 100644 index 000000000..2e52ebdb2 --- /dev/null +++ b/pkg/mongodb/MongodbContext.php @@ -0,0 +1,166 @@ +config = array_replace([ + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + 'polling_interval' => null, + ], $config); + + $this->client = $client; + } + + /** + * @return MongodbMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + $message = new MongodbMessage(); + $message->setBody($body); + $message->setProperties($properties); + $message->setHeaders($headers); + + return $message; + } + + /** + * @return MongodbDestination + */ + public function createTopic(string $name): Topic + { + return new MongodbDestination($name); + } + + /** + * @return MongodbDestination + */ + public function createQueue(string $queueName): Queue + { + return new MongodbDestination($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return MongodbProducer + */ + public function createProducer(): Producer + { + return new MongodbProducer($this); + } + + /** + * @param MongodbDestination $destination + * + * @return MongodbConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, MongodbDestination::class); + + $consumer = new MongodbConsumer($this, $destination); + + if (isset($this->config['polling_interval'])) { + $consumer->setPollingInterval($this->config['polling_interval']); + } + + return $consumer; + } + + public function close(): void + { + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new MongodbSubscriptionConsumer($this); + } + + /** + * @internal It must be used here and in the consumer only + */ + public function convertMessage(array $mongodbMessage): MongodbMessage + { + $mongodbMessageObj = $this->createMessage( + $mongodbMessage['body'], + JSON::decode($mongodbMessage['properties']), + JSON::decode($mongodbMessage['headers']) + ); + + $mongodbMessageObj->setId((string) $mongodbMessage['_id']); + $mongodbMessageObj->setPriority((int) $mongodbMessage['priority']); + $mongodbMessageObj->setRedelivered((bool) $mongodbMessage['redelivered']); + $mongodbMessageObj->setPublishedAt((int) $mongodbMessage['published_at']); + + return $mongodbMessageObj; + } + + /** + * @param MongodbDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + $this->getCollection()->deleteMany([ + 'queue' => $queue->getQueueName(), + ]); + } + + public function getCollection(): Collection + { + return $this->client + ->selectDatabase($this->config['dbname']) + ->selectCollection($this->config['collection_name']); + } + + public function getClient(): Client + { + return $this->client; + } + + public function getConfig(): array + { + return $this->config; + } + + public function createCollection(): void + { + $collection = $this->getCollection(); + $collection->createIndex(['queue' => 1], ['name' => 'enqueue_queue']); + $collection->createIndex(['priority' => -1, 'published_at' => 1], ['name' => 'enqueue_priority']); + $collection->createIndex(['delayed_until' => 1], ['name' => 'enqueue_delayed']); + $collection->createIndex(['queue' => 1, 'priority' => -1, 'published_at' => 1, 'delayed_until' => 1], ['name' => 'enqueue_combined']); + } +} diff --git a/pkg/mongodb/MongodbDestination.php b/pkg/mongodb/MongodbDestination.php new file mode 100644 index 000000000..06653b4b9 --- /dev/null +++ b/pkg/mongodb/MongodbDestination.php @@ -0,0 +1,36 @@ +destinationName = $name; + } + + public function getQueueName(): string + { + return $this->destinationName; + } + + public function getTopicName(): string + { + return $this->destinationName; + } + + public function getName(): string + { + return $this->destinationName; + } +} diff --git a/pkg/mongodb/MongodbMessage.php b/pkg/mongodb/MongodbMessage.php new file mode 100644 index 000000000..fadc5dd4e --- /dev/null +++ b/pkg/mongodb/MongodbMessage.php @@ -0,0 +1,228 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setId(?string $id = null): void + { + $this->id = $id; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function getPriority(): ?int + { + return $this->priority; + } + + public function setPriority(?int $priority = null): void + { + $this->priority = $priority; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * In milliseconds. + */ + public function setDeliveryDelay(?int $deliveryDelay = null): void + { + $this->deliveryDelay = $deliveryDelay; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } + + /** + * In milliseconds. + */ + public function setTimeToLive(?int $timeToLive = null): void + { + $this->timeToLive = $timeToLive; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id', null); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id', null); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function getPublishedAt(): ?int + { + return $this->publishedAt; + } + + /** + * In milliseconds. + */ + public function setPublishedAt(?int $publishedAt = null): void + { + $this->publishedAt = $publishedAt; + } +} diff --git a/pkg/mongodb/MongodbProducer.php b/pkg/mongodb/MongodbProducer.php new file mode 100644 index 000000000..ed28a6681 --- /dev/null +++ b/pkg/mongodb/MongodbProducer.php @@ -0,0 +1,155 @@ +context = $context; + } + + /** + * @param MongodbDestination $destination + * @param MongodbMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, MongodbDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, MongodbMessage::class); + + if (null !== $this->priority && null === $message->getPriority()) { + $message->setPriority($this->priority); + } + if (null !== $this->deliveryDelay && null === $message->getDeliveryDelay()) { + $message->setDeliveryDelay($this->deliveryDelay); + } + if (null !== $this->timeToLive && null === $message->getTimeToLive()) { + $message->setTimeToLive($this->timeToLive); + } + + $body = $message->getBody(); + + $publishedAt = null !== $message->getPublishedAt() ? + $message->getPublishedAt() : + (int) (microtime(true) * 10000) + ; + + $mongoMessage = [ + 'published_at' => $publishedAt, + 'body' => $body, + 'headers' => JSON::encode($message->getHeaders()), + 'properties' => JSON::encode($message->getProperties()), + 'priority' => $message->getPriority(), + 'queue' => $destination->getName(), + 'redelivered' => $message->isRedelivered(), + ]; + + $delay = $message->getDeliveryDelay(); + if ($delay) { + if (!is_int($delay)) { + throw new \LogicException(sprintf('Delay must be integer but got: "%s"', is_object($delay) ? $delay::class : gettype($delay))); + } + + if ($delay <= 0) { + throw new \LogicException(sprintf('Delay must be positive integer but got: "%s"', $delay)); + } + + $mongoMessage['delayed_until'] = time() + (int) $delay / 1000; + } + + $timeToLive = $message->getTimeToLive(); + if ($timeToLive) { + if (!is_int($timeToLive)) { + throw new \LogicException(sprintf('TimeToLive must be integer but got: "%s"', is_object($timeToLive) ? $timeToLive::class : gettype($timeToLive))); + } + + if ($timeToLive <= 0) { + throw new \LogicException(sprintf('TimeToLive must be positive integer but got: "%s"', $timeToLive)); + } + + $mongoMessage['time_to_live'] = time() + (int) $timeToLive / 1000; + } + + try { + $collection = $this->context->getCollection(); + $collection->insertOne($mongoMessage); + } catch (\Exception $e) { + throw new Exception('The transport has failed to send the message due to some internal error.', $e->getCode(), $e); + } + } + + /** + * @return self + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return self + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return self + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/pkg/mongodb/MongodbSubscriptionConsumer.php b/pkg/mongodb/MongodbSubscriptionConsumer.php new file mode 100644 index 000000000..9fa6245f4 --- /dev/null +++ b/pkg/mongodb/MongodbSubscriptionConsumer.php @@ -0,0 +1,133 @@ +context = $context; + $this->subscribers = []; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('No subscribers'); + } + + $timeout = (int) ceil($timeout / 1000); + $endAt = time() + $timeout; + + $queueNames = []; + foreach (array_keys($this->subscribers) as $queueName) { + $queueNames[$queueName] = $queueName; + } + + $currentQueueNames = []; + while (true) { + if (empty($currentQueueNames)) { + $currentQueueNames = $queueNames; + } + + $result = $this->context->getCollection()->findOneAndDelete( + [ + 'queue' => ['$in' => array_keys($currentQueueNames)], + '$or' => [ + ['delayed_until' => ['$exists' => false]], + ['delayed_until' => ['$lte' => time()]], + ], + ], + [ + 'sort' => ['priority' => -1, 'published_at' => 1], + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ] + ); + + if ($result) { + list($consumer, $callback) = $this->subscribers[$result['queue']]; + + $message = $this->context->convertMessage($result); + + if (false === call_user_func($callback, $message, $consumer)) { + return; + } + + unset($currentQueueNames[$result['queue']]); + } else { + $currentQueueNames = []; + + usleep(200000); // 200ms + } + + if ($timeout && microtime(true) >= $endAt) { + return; + } + } + } + + /** + * @param MongodbConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof MongodbConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', MongodbConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + if (array_key_exists($queueName, $this->subscribers)) { + if ($this->subscribers[$queueName][0] === $consumer && $this->subscribers[$queueName][1] === $callback) { + return; + } + + throw new \InvalidArgumentException(sprintf('There is a consumer subscribed to queue: "%s"', $queueName)); + } + + $this->subscribers[$queueName] = [$consumer, $callback]; + } + + /** + * @param MongodbConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof MongodbConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', MongodbConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + + if (false == array_key_exists($queueName, $this->subscribers)) { + return; + } + + if ($this->subscribers[$queueName][0] !== $consumer) { + return; + } + + unset($this->subscribers[$queueName]); + } + + public function unsubscribeAll(): void + { + $this->subscribers = []; + } +} diff --git a/pkg/mongodb/README.md b/pkg/mongodb/README.md new file mode 100644 index 000000000..2e9bbd1fc --- /dev/null +++ b/pkg/mongodb/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Mongodb Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/mongodb/ci.yml?branch=master)](https://github.com/php-enqueue/mongodb/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/mongodb/d/total.png)](https://packagist.org/packages/enqueue/mongodb) +[![Latest Stable Version](https://poser.pugx.org/enqueue/mongodb/version.png)](https://packagist.org/packages/enqueue/mongodb) + +This is an implementation of the queue specification. It allows you to use MongoDB database as a message broker. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/mongodb/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/mongodb/Tests/Functional/MongodbConsumerTest.php b/pkg/mongodb/Tests/Functional/MongodbConsumerTest.php new file mode 100644 index 000000000..b6b644beb --- /dev/null +++ b/pkg/mongodb/Tests/Functional/MongodbConsumerTest.php @@ -0,0 +1,104 @@ +context = $this->buildMongodbContext(); + } + + protected function tearDown(): void + { + if ($this->context) { + $this->context->close(); + } + + parent::tearDown(); + } + + public function testShouldSetPublishedAtDateToReceivedMessage() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertNull($consumer->receiveNoWait()); + + $time = (int) (microtime(true) * 10000); + + $expectedBody = __CLASS__.$time; + + $producer = $context->createProducer(); + + $message = $context->createMessage($expectedBody); + $message->setPublishedAt($time); + $producer->send($queue, $message); + + $message = $consumer->receive(8000); // 8 sec + + $this->assertInstanceOf(MongodbMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedBody, $message->getBody()); + $this->assertSame($time, $message->getPublishedAt()); + } + + public function testShouldOrderMessagesWithSamePriorityByPublishedAtDate() + { + $context = $this->context; + $queue = $context->createQueue(__METHOD__); + + $consumer = $context->createConsumer($queue); + + // guard + $this->assertNull($consumer->receiveNoWait()); + + $time = (int) (microtime(true) * 10000); + $olderTime = $time - 10000; + + $expectedPriority5Body = __CLASS__.'_priority5_'.$time; + $expectedPriority5BodyOlderTime = __CLASS__.'_priority5Old_'.$olderTime; + + $producer = $context->createProducer(); + + $message = $context->createMessage($expectedPriority5Body); + $message->setPriority(5); + $message->setPublishedAt($time); + $producer->send($queue, $message); + + $message = $context->createMessage($expectedPriority5BodyOlderTime); + $message->setPriority(5); + $message->setPublishedAt($olderTime); + $producer->send($queue, $message); + + $message = $consumer->receive(8000); // 8 sec + + $this->assertInstanceOf(MongodbMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedPriority5BodyOlderTime, $message->getBody()); + + $message = $consumer->receive(8000); // 8 sec + + $this->assertInstanceOf(MongodbMessage::class, $message); + $consumer->acknowledge($message); + $this->assertSame($expectedPriority5Body, $message->getBody()); + } +} diff --git a/pkg/mongodb/Tests/MongodbConnectionFactoryTest.php b/pkg/mongodb/Tests/MongodbConnectionFactoryTest.php new file mode 100644 index 000000000..d5dd9ca45 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbConnectionFactoryTest.php @@ -0,0 +1,72 @@ +assertClassImplements(ConnectionFactory::class, MongodbConnectionFactory::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $params = [ + 'dsn' => 'mongodb://127.0.0.1/', + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + ]; + + $factory = new MongodbConnectionFactory(); + $this->assertAttributeEquals($params, 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $params = [ + 'dsn' => 'mongodb://127.0.0.3/', + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + ]; + + $factory = new MongodbConnectionFactory($params); + + $this->assertAttributeEquals($params, 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfigurationFromDsn() + { + $params = [ + 'dsn' => 'mongodb://127.0.0.3/test-db-name?enqueue_collection=collection-name&polling_interval=3000', + 'dbname' => 'test-db-name', + 'collection_name' => 'collection-name', + 'polling_interval' => 3000, + ]; + + $factory = new MongodbConnectionFactory($params['dsn']); + + $this->assertAttributeEquals($params, 'config', $factory); + } + + public function testShouldCreateContext() + { + $factory = new MongodbConnectionFactory(); + + $context = $factory->createContext(); + + $this->assertInstanceOf(MongodbContext::class, $context); + } +} diff --git a/pkg/mongodb/Tests/MongodbConsumerTest.php b/pkg/mongodb/Tests/MongodbConsumerTest.php new file mode 100644 index 000000000..6cd597514 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbConsumerTest.php @@ -0,0 +1,219 @@ +assertClassImplements(Consumer::class, MongodbConsumer::class); + } + + public function testShouldReturnInstanceOfDestination() + { + $destination = new MongodbDestination('queue'); + + $consumer = new MongodbConsumer($this->createContextMock(), $destination); + + $this->assertSame($destination, $consumer->getQueue()); + } + + /** + * @doesNotPerformAssertions + */ + public function testCouldCallAcknowledgedMethod() + { + $consumer = new MongodbConsumer($this->createContextMock(), new MongodbDestination('queue')); + $consumer->acknowledge(new MongodbMessage()); + } + + public function testCouldSetAndGetPollingInterval() + { + $destination = new MongodbDestination('queue'); + + $consumer = new MongodbConsumer($this->createContextMock(), $destination); + $consumer->setPollingInterval(123456); + + $this->assertEquals(123456, $consumer->getPollingInterval()); + } + + public function testRejectShouldThrowIfInstanceOfMessageIsInvalid() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage( + 'The message must be an instance of '. + 'Enqueue\Mongodb\MongodbMessage '. + 'but it is Enqueue\Mongodb\Tests\InvalidMessage.' + ); + + $consumer = new MongodbConsumer($this->createContextMock(), new MongodbDestination('queue')); + $consumer->reject(new InvalidMessage()); + } + + public function testShouldDoNothingOnReject() + { + $queue = new MongodbDestination('queue'); + + $message = new MongodbMessage(); + $message->setBody('theBody'); + + $context = $this->createContextMock(); + $context + ->expects($this->never()) + ->method('createProducer') + ; + + $consumer = new MongodbConsumer($context, $queue); + + $consumer->reject($message); + } + + public function testRejectShouldReSendMessageToSameQueueOnRequeue() + { + $queue = new MongodbDestination('queue'); + + $message = new MongodbMessage(); + $message->setBody('theBody'); + + $producerMock = $this->createProducerMock(); + $producerMock + ->expects($this->once()) + ->method('send') + ->with($this->identicalTo($queue), $this->identicalTo($message)) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('createProducer') + ->willReturn($producerMock) + ; + + $consumer = new MongodbConsumer($context, $queue); + + $consumer->reject($message, true); + } + + /** + * @return MongodbProducer|\PHPUnit\Framework\MockObject\MockObject + */ + private function createProducerMock() + { + return $this->createMock(MongodbProducer::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|MongodbContext + */ + private function createContextMock() + { + return $this->createMock(MongodbContext::class); + } +} + +class InvalidMessage implements Message +{ + public function getBody(): string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setBody(string $body): void + { + } + + public function setProperties(array $properties): void + { + } + + public function getProperties(): array + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setProperty(string $name, $value): void + { + } + + public function getProperty(string $name, $default = null) + { + } + + public function setHeaders(array $headers): void + { + } + + public function getHeaders(): array + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setHeader(string $name, $value): void + { + } + + public function getHeader(string $name, $default = null) + { + } + + public function setRedelivered(bool $redelivered): void + { + } + + public function isRedelivered(): bool + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setCorrelationId(?string $correlationId = null): void + { + } + + public function getCorrelationId(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setMessageId(?string $messageId = null): void + { + } + + public function getMessageId(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function getTimestamp(): ?int + { + throw new \BadMethodCallException('This should not be called directly'); + } + + public function setTimestamp(?int $timestamp = null): void + { + } + + public function setReplyTo(?string $replyTo = null): void + { + } + + public function getReplyTo(): ?string + { + throw new \BadMethodCallException('This should not be called directly'); + } +} diff --git a/pkg/mongodb/Tests/MongodbContextTest.php b/pkg/mongodb/Tests/MongodbContextTest.php new file mode 100644 index 000000000..8cdef79ff --- /dev/null +++ b/pkg/mongodb/Tests/MongodbContextTest.php @@ -0,0 +1,193 @@ +assertClassImplements(Context::class, MongodbContext::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $context = new MongodbContext($this->createClientMock(), []); + + $this->assertAttributeEquals([ + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + 'polling_interval' => null, + ], 'config', $context); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $client = new MongodbContext($this->createClientMock(), [ + 'dbname' => 'testDbName', + 'collection_name' => 'testCollectionName', + 'polling_interval' => 123456, + ]); + + $this->assertAttributeEquals([ + 'dbname' => 'testDbName', + 'collection_name' => 'testCollectionName', + 'polling_interval' => 123456, + ], 'config', $client); + } + + public function testShouldCreateMessage() + { + $context = new MongodbContext($this->createClientMock()); + $message = $context->createMessage('body', ['pkey' => 'pval'], ['hkey' => 'hval']); + + $this->assertInstanceOf(MongodbMessage::class, $message); + $this->assertEquals('body', $message->getBody()); + $this->assertEquals(['pkey' => 'pval'], $message->getProperties()); + $this->assertEquals(['hkey' => 'hval'], $message->getHeaders()); + $this->assertNull($message->getPriority()); + $this->assertFalse($message->isRedelivered()); + } + + public function testShouldConvertFromArrayToMongodbMessage() + { + $arrayData = [ + '_id' => 'stringId', + 'body' => 'theBody', + 'properties' => json_encode(['barProp' => 'barPropVal']), + 'headers' => json_encode(['fooHeader' => 'fooHeaderVal']), + 'priority' => '12', + 'published_at' => 1525935820, + 'redelivered' => false, + ]; + + $context = new MongodbContext($this->createClientMock()); + $message = $context->convertMessage($arrayData); + + $this->assertInstanceOf(MongodbMessage::class, $message); + + $this->assertEquals('stringId', $message->getId()); + $this->assertEquals('theBody', $message->getBody()); + $this->assertEquals(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertEquals(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + $this->assertEquals(12, $message->getPriority()); + $this->assertEquals(1525935820, $message->getPublishedAt()); + $this->assertFalse($message->isRedelivered()); + } + + public function testShouldCreateTopic() + { + $context = new MongodbContext($this->createClientMock()); + $topic = $context->createTopic('topic'); + + $this->assertInstanceOf(MongodbDestination::class, $topic); + $this->assertEquals('topic', $topic->getTopicName()); + } + + public function testShouldCreateQueue() + { + $context = new MongodbContext($this->createClientMock()); + $queue = $context->createQueue('queue'); + + $this->assertInstanceOf(MongodbDestination::class, $queue); + $this->assertEquals('queue', $queue->getName()); + } + + public function testShouldCreateProducer() + { + $context = new MongodbContext($this->createClientMock()); + + $this->assertInstanceOf(MongodbProducer::class, $context->createProducer()); + } + + public function testShouldCreateConsumer() + { + $context = new MongodbContext($this->createClientMock()); + + $this->assertInstanceOf(MongodbConsumer::class, $context->createConsumer(new MongodbDestination(''))); + } + + public function testShouldCreateMessageConsumerAndSetPollingInterval() + { + $context = new MongodbContext($this->createClientMock(), [ + 'polling_interval' => 123456, + ]); + + $consumer = $context->createConsumer(new MongodbDestination('')); + + $this->assertInstanceOf(MongodbConsumer::class, $consumer); + $this->assertEquals(123456, $consumer->getPollingInterval()); + } + + public function testShouldThrowIfDestinationIsInvalidInstanceType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage( + 'The destination must be an instance of '. + 'Enqueue\Mongodb\MongodbDestination but got '. + 'Enqueue\Mongodb\Tests\NotSupportedDestination2.' + ); + + $context = new MongodbContext($this->createClientMock()); + + $this->assertInstanceOf(MongodbConsumer::class, $context->createConsumer(new NotSupportedDestination2())); + } + + public function testShouldReturnInstanceOfClient() + { + $context = new MongodbContext($client = $this->createClientMock()); + + $this->assertSame($client, $context->getClient()); + } + + public function testShouldReturnConfig() + { + $context = new MongodbContext($this->createClientMock()); + + $this->assertSame([ + 'dbname' => 'enqueue', + 'collection_name' => 'enqueue', + 'polling_interval' => null, + ], $context->getConfig()); + } + + public function testShouldThrowNotSupportedOnCreateTemporaryQueueCall() + { + $context = new MongodbContext($this->createClientMock()); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Client + */ + private function createClientMock() + { + return $this->createMock(Client::class); + } +} + +class NotSupportedDestination2 implements Destination +{ +} diff --git a/pkg/mongodb/Tests/MongodbDestinationTest.php b/pkg/mongodb/Tests/MongodbDestinationTest.php new file mode 100644 index 000000000..4e94ef018 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbDestinationTest.php @@ -0,0 +1,41 @@ +assertClassImplements(Destination::class, MongodbDestination::class); + } + + public function testShouldImplementTopicInterface() + { + $this->assertClassImplements(Topic::class, MongodbDestination::class); + } + + public function testShouldImplementQueueInterface() + { + $this->assertClassImplements(Queue::class, MongodbDestination::class); + } + + public function testShouldReturnTopicAndQueuePreviouslySetInConstructor() + { + $destination = new MongodbDestination('topic-or-queue-name'); + + $this->assertSame('topic-or-queue-name', $destination->getName()); + $this->assertSame('topic-or-queue-name', $destination->getTopicName()); + } +} diff --git a/pkg/mongodb/Tests/MongodbMessageTest.php b/pkg/mongodb/Tests/MongodbMessageTest.php new file mode 100644 index 000000000..391d8f7a2 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbMessageTest.php @@ -0,0 +1,95 @@ +assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new MongodbMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } + + public function testShouldSetNullPriorityInConstructor() + { + $message = new MongodbMessage(); + + $this->assertNull($message->getPriority()); + } + + public function testShouldSetDelayToNullInConstructor() + { + $message = new MongodbMessage(); + + $this->assertNull($message->getDeliveryDelay()); + } + + public function testShouldSetCorrelationIdAsHeader() + { + $message = new MongodbMessage(); + $message->setCorrelationId('theCorrelationId'); + + $this->assertSame(['correlation_id' => 'theCorrelationId'], $message->getHeaders()); + } + + public function testShouldSetPublishedAtToNullInConstructor() + { + $message = new MongodbMessage(); + + $this->assertNull($message->getPublishedAt()); + } + + public function testShouldSetMessageIdAsHeader() + { + $message = new MongodbMessage(); + $message->setMessageId('theMessageId'); + + $this->assertSame(['message_id' => 'theMessageId'], $message->getHeaders()); + } + + public function testShouldSetTimestampAsHeader() + { + $message = new MongodbMessage(); + $message->setTimestamp(12345); + + $this->assertSame(['timestamp' => 12345], $message->getHeaders()); + } + + public function testShouldSetReplyToAsHeader() + { + $message = new MongodbMessage(); + $message->setReplyTo('theReply'); + + $this->assertSame(['reply_to' => 'theReply'], $message->getHeaders()); + } + + public function testShouldAllowGetPreviouslySetPublishedAtTime() + { + $message = new MongodbMessage(); + + $message->setPublishedAt(123); + + $this->assertSame(123, $message->getPublishedAt()); + } +} diff --git a/pkg/mongodb/Tests/MongodbProducerTest.php b/pkg/mongodb/Tests/MongodbProducerTest.php new file mode 100644 index 000000000..6987b1a76 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbProducerTest.php @@ -0,0 +1,51 @@ +assertClassImplements(Producer::class, MongodbProducer::class); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage( + 'The destination must be an instance of '. + 'Enqueue\Mongodb\MongodbDestination but got '. + 'Enqueue\Mongodb\Tests\NotSupportedDestination1.' + ); + + $producer = new MongodbProducer($this->createContextMock()); + + $producer->send(new NotSupportedDestination1(), new MongodbMessage()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|MongodbContext + */ + private function createContextMock() + { + return $this->createMock(MongodbContext::class); + } +} + +class NotSupportedDestination1 implements Destination +{ +} diff --git a/pkg/mongodb/Tests/MongodbSubscriptionConsumerTest.php b/pkg/mongodb/Tests/MongodbSubscriptionConsumerTest.php new file mode 100644 index 000000000..d982e0418 --- /dev/null +++ b/pkg/mongodb/Tests/MongodbSubscriptionConsumerTest.php @@ -0,0 +1,178 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + public function testShouldAddConsumerAndCallbackToSubscribersPropertyOnSubscribe() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + + $this->assertAttributeSame([ + 'foo_queue' => [$fooConsumer, $fooCallback], + 'bar_queue' => [$barConsumer, $barCallback], + ], 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTrySubscribeAnotherConsumerToAlreadySubscribedQueue() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('There is a consumer subscribed to queue: "foo_queue"'); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAllowSubscribeSameConsumerAndCallbackSecondTime() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + } + + public function testShouldRemoveSubscribedConsumerOnUnsubscribeCall() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $fooConsumer = $this->createConsumerStub('foo_queue'); + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, function () {}); + $subscriptionConsumer->subscribe($barConsumer, function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($fooConsumer); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedQueueName() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('bar_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedConsumer() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('foo_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldRemoveAllSubscriberOnUnsubscribeAllCall() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + $subscriptionConsumer->subscribe($this->createConsumerStub('bar_queue'), function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribeAll(); + + $this->assertAttributeCount(0, 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTryConsumeWithoutSubscribers() + { + $subscriptionConsumer = new MongodbSubscriptionConsumer($this->createMongodbContextMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('No subscribers'); + + $subscriptionConsumer->consume(); + } + + /** + * @return MongodbContext|\PHPUnit\Framework\MockObject\MockObject + */ + private function createMongodbContextMock() + { + return $this->createMock(MongodbContext::class); + } + + /** + * @param mixed|null $queueName + * + * @return Consumer|\PHPUnit\Framework\MockObject\MockObject + */ + private function createConsumerStub($queueName = null) + { + $queueMock = $this->createMock(Queue::class); + $queueMock + ->expects($this->any()) + ->method('getQueueName') + ->willReturn($queueName); + + $consumerMock = $this->createMock(MongodbConsumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queueMock) + ; + + return $consumerMock; + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbConnectionFactoryTest.php b/pkg/mongodb/Tests/Spec/MongodbConnectionFactoryTest.php new file mode 100644 index 000000000..9f0d195ba --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbConnectionFactoryTest.php @@ -0,0 +1,17 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbMessageTest.php b/pkg/mongodb/Tests/Spec/MongodbMessageTest.php new file mode 100644 index 000000000..92983d430 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbMessageTest.php @@ -0,0 +1,17 @@ +buildMongodbContext()->createProducer(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbQueueTest.php new file mode 100644 index 000000000..25e437ba6 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbQueueTest.php @@ -0,0 +1,17 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveDelayedMessageFromQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveDelayedMessageFromQueueTest.php new file mode 100644 index 000000000..f54513fae --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveDelayedMessageFromQueueTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendAndReceivePriorityMessagesFromQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbSendAndReceivePriorityMessagesFromQueueTest.php new file mode 100644 index 000000000..6aadef7ba --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendAndReceivePriorityMessagesFromQueueTest.php @@ -0,0 +1,51 @@ +publishedAt = (int) (microtime(true) * 10000); + } + + /** + * @return Context + */ + protected function createContext() + { + return $this->buildMongodbContext(); + } + + /** + * @param MongodbContext $context + * + * @return MongodbMessage + */ + protected function createMessage(Context $context, $body) + { + /** @var MongodbMessage $message */ + $message = parent::createMessage($context, $body); + + // in order to test priorities correctly we have to make sure the messages were sent in the same time. + $message->setPublishedAt($this->publishedAt); + + return $message; + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveTimeToLiveMessagesFromQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveTimeToLiveMessagesFromQueueTest.php new file mode 100644 index 000000000..f16e80b60 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendAndReceiveTimeToLiveMessagesFromQueueTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..c9b9cb2d1 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromQueueTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromTopicTest.php b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..a416d3c11 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveFromTopicTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromQueueTest.php b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..43ae34c6b --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromTopicTest.php b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..0fe9f0e56 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,20 @@ +buildMongodbContext(); + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..2fe16e860 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,40 @@ +buildMongodbContext(); + } + + /** + * @param MongodbContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var MongodbDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..b18e0bf0d --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,40 @@ +buildMongodbContext(); + } + + /** + * @param MongodbContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var MongodbDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerStopOnFalseTest.php b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..3acfa94ed --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,40 @@ +buildMongodbContext(); + } + + /** + * @param MongodbContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var MongodbDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->getClient()->dropDatabase($queueName); + + return $queue; + } +} diff --git a/pkg/mongodb/Tests/Spec/MongodbTopicTest.php b/pkg/mongodb/Tests/Spec/MongodbTopicTest.php new file mode 100644 index 000000000..ab5c025a2 --- /dev/null +++ b/pkg/mongodb/Tests/Spec/MongodbTopicTest.php @@ -0,0 +1,17 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/monitoring/.gitattributes b/pkg/monitoring/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/monitoring/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/monitoring/.github/workflows/ci.yml b/pkg/monitoring/.github/workflows/ci.yml new file mode 100644 index 000000000..5448d7b1a --- /dev/null +++ b/pkg/monitoring/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/monitoring/.gitignore b/pkg/monitoring/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/monitoring/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/monitoring/ClientMonitoringExtension.php b/pkg/monitoring/ClientMonitoringExtension.php new file mode 100644 index 000000000..11e7054f7 --- /dev/null +++ b/pkg/monitoring/ClientMonitoringExtension.php @@ -0,0 +1,64 @@ +storage = $storage; + $this->logger = $logger; + } + + public function onPostSend(PostSend $context): void + { + $timestampMs = (int) (microtime(true) * 1000); + + $destination = $context->getTransportDestination() instanceof Topic + ? $context->getTransportDestination()->getTopicName() + : $context->getTransportDestination()->getQueueName() + ; + + $stats = new SentMessageStats( + $timestampMs, + $destination, + $context->getTransportDestination() instanceof Topic, + $context->getTransportMessage()->getMessageId(), + $context->getTransportMessage()->getCorrelationId(), + $context->getTransportMessage()->getHeaders(), + $context->getTransportMessage()->getProperties() + ); + + $this->safeCall(function () use ($stats) { + $this->storage->pushSentMessageStats($stats); + }); + } + + private function safeCall(callable $fun) + { + try { + return call_user_func($fun); + } catch (\Throwable $e) { + $this->logger->error(sprintf('[ClientMonitoringExtension] Push to storage failed: %s', $e->getMessage())); + } + + return null; + } +} diff --git a/pkg/monitoring/ConsumedMessageStats.php b/pkg/monitoring/ConsumedMessageStats.php new file mode 100644 index 000000000..077ceebdf --- /dev/null +++ b/pkg/monitoring/ConsumedMessageStats.php @@ -0,0 +1,210 @@ +consumerId = $consumerId; + $this->timestampMs = $timestampMs; + $this->receivedAtMs = $receivedAtMs; + $this->queue = $queue; + $this->messageId = $messageId; + $this->correlationId = $correlationId; + $this->headers = $headers; + $this->properties = $properties; + $this->redelivered = $redelivered; + $this->status = $status; + + $this->errorClass = $errorClass; + $this->errorMessage = $errorMessage; + $this->errorCode = $errorCode; + $this->errorFile = $errorFile; + $this->errorLine = $errorLine; + $this->trance = $trace; + } + + public function getConsumerId(): string + { + return $this->consumerId; + } + + public function getTimestampMs(): int + { + return $this->timestampMs; + } + + public function getReceivedAtMs(): int + { + return $this->receivedAtMs; + } + + public function getQueue(): string + { + return $this->queue; + } + + public function getMessageId(): ?string + { + return $this->messageId; + } + + public function getCorrelationId(): ?string + { + return $this->correlationId; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getErrorClass(): ?string + { + return $this->errorClass; + } + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function getErrorCode(): ?int + { + return $this->errorCode; + } + + public function getErrorFile(): ?string + { + return $this->errorFile; + } + + public function getErrorLine(): ?int + { + return $this->errorLine; + } + + public function getTrance(): ?string + { + return $this->trance; + } +} diff --git a/pkg/monitoring/ConsumerMonitoringExtension.php b/pkg/monitoring/ConsumerMonitoringExtension.php new file mode 100644 index 000000000..31e97697d --- /dev/null +++ b/pkg/monitoring/ConsumerMonitoringExtension.php @@ -0,0 +1,320 @@ +storage = $storage; + $this->updateStatsPeriod = 60; + } + + public function onStart(Start $context): void + { + $this->consumerId = UUID::generate(); + + $this->queues = []; + + $this->startedAtMs = 0; + $this->lastStatsAt = 0; + + $this->received = 0; + $this->acknowledged = 0; + $this->rejected = 0; + $this->requeued = 0; + } + + public function onPreSubscribe(PreSubscribe $context): void + { + $this->queues[] = $context->getConsumer()->getQueue()->getQueueName(); + } + + public function onPreConsume(PreConsume $context): void + { + // send started only once + $isStarted = false; + if (0 === $this->startedAtMs) { + $isStarted = true; + $this->startedAtMs = $context->getStartTime(); + } + + // send stats event only once per period + $time = time(); + if (($time - $this->lastStatsAt) > $this->updateStatsPeriod) { + $this->lastStatsAt = $time; + + $event = new ConsumerStats( + $this->consumerId, + $this->getNowMs(), + $this->startedAtMs, + null, + $isStarted, + false, + false, + $this->queues, + $this->received, + $this->acknowledged, + $this->rejected, + $this->requeued, + $this->getMemoryUsage(), + $this->getSystemLoad() + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumerStats($event); + }, $context->getLogger()); + } + } + + public function onEnd(End $context): void + { + $event = new ConsumerStats( + $this->consumerId, + $this->getNowMs(), + $this->startedAtMs, + $context->getEndTime(), + false, + true, + false, + $this->queues, + $this->received, + $this->acknowledged, + $this->rejected, + $this->requeued, + $this->getMemoryUsage(), + $this->getSystemLoad() + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumerStats($event); + }, $context->getLogger()); + } + + public function onProcessorException(ProcessorException $context): void + { + $timeMs = $this->getNowMs(); + + $event = new ConsumedMessageStats( + $this->consumerId, + $timeMs, + $context->getReceivedAt(), + $context->getConsumer()->getQueue()->getQueueName(), + $context->getMessage()->getMessageId(), + $context->getMessage()->getCorrelationId(), + $context->getMessage()->getHeaders(), + $context->getMessage()->getProperties(), + $context->getMessage()->isRedelivered(), + ConsumedMessageStats::STATUS_FAILED, + get_class($context->getException()), + $context->getException()->getMessage(), + $context->getException()->getCode(), + $context->getException()->getFile(), + $context->getException()->getLine(), + $context->getException()->getTraceAsString() + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumedMessageStats($event); + }, $context->getLogger()); + + // priority of this extension must be the lowest and + // if result is null we emit consumer stopped event here + if (null === $context->getResult()) { + $event = new ConsumerStats( + $this->consumerId, + $timeMs, + $this->startedAtMs, + $timeMs, + false, + true, + true, + $this->queues, + $this->received, + $this->acknowledged, + $this->rejected, + $this->requeued, + $this->getMemoryUsage(), + $this->getSystemLoad(), + get_class($context->getException()), + $context->getException()->getMessage(), + $context->getException()->getCode(), + $context->getException()->getFile(), + $context->getException()->getLine(), + $context->getException()->getTraceAsString() + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumerStats($event); + }, $context->getLogger()); + } + } + + public function onMessageReceived(MessageReceived $context): void + { + ++$this->received; + } + + public function onResult(MessageResult $context): void + { + $timeMs = $this->getNowMs(); + + switch ($context->getResult()) { + case Result::ACK: + case Result::ALREADY_ACKNOWLEDGED: + $this->acknowledged++; + $status = ConsumedMessageStats::STATUS_ACK; + break; + case Result::REJECT: + $this->rejected++; + $status = ConsumedMessageStats::STATUS_REJECTED; + break; + case Result::REQUEUE: + $this->requeued++; + $status = ConsumedMessageStats::STATUS_REQUEUED; + break; + default: + throw new \LogicException(); + } + + $event = new ConsumedMessageStats( + $this->consumerId, + $timeMs, + $context->getReceivedAt(), + $context->getConsumer()->getQueue()->getQueueName(), + $context->getMessage()->getMessageId(), + $context->getMessage()->getCorrelationId(), + $context->getMessage()->getHeaders(), + $context->getMessage()->getProperties(), + $context->getMessage()->isRedelivered(), + $status + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumedMessageStats($event); + }, $context->getLogger()); + + // send stats event only once per period + $time = time(); + if (($time - $this->lastStatsAt) > $this->updateStatsPeriod) { + $this->lastStatsAt = $time; + + $event = new ConsumerStats( + $this->consumerId, + $timeMs, + $this->startedAtMs, + null, + false, + false, + false, + $this->queues, + $this->received, + $this->acknowledged, + $this->rejected, + $this->requeued, + $this->getMemoryUsage(), + $this->getSystemLoad() + ); + + $this->safeCall(function () use ($event) { + $this->storage->pushConsumerStats($event); + }, $context->getLogger()); + } + } + + private function getNowMs(): int + { + return (int) (microtime(true) * 1000); + } + + private function getMemoryUsage(): int + { + return memory_get_usage(true); + } + + private function getSystemLoad(): float + { + return sys_getloadavg()[0]; + } + + private function safeCall(callable $fun, LoggerInterface $logger) + { + try { + return call_user_func($fun); + } catch (\Throwable $e) { + $logger->error(sprintf('[ConsumerMonitoringExtension] Push to storage failed: %s', $e->getMessage())); + } + + return null; + } +} diff --git a/pkg/monitoring/ConsumerStats.php b/pkg/monitoring/ConsumerStats.php new file mode 100644 index 000000000..d281b532d --- /dev/null +++ b/pkg/monitoring/ConsumerStats.php @@ -0,0 +1,257 @@ +consumerId = $consumerId; + $this->timestampMs = $timestampMs; + $this->startedAtMs = $startedAtMs; + $this->finishedAtMs = $finishedAtMs; + + $this->started = $started; + $this->finished = $finished; + $this->failed = $failed; + + $this->queues = $queues; + $this->startedAtMs = $startedAtMs; + $this->received = $received; + $this->acknowledged = $acknowledged; + $this->rejected = $rejected; + $this->requeued = $requeued; + + $this->memoryUsage = $memoryUsage; + $this->systemLoad = $systemLoad; + + $this->errorClass = $errorClass; + $this->errorMessage = $errorMessage; + $this->errorCode = $errorCode; + $this->errorFile = $errorFile; + $this->errorLine = $errorLine; + $this->trance = $trace; + } + + public function getConsumerId(): string + { + return $this->consumerId; + } + + public function getTimestampMs(): int + { + return $this->timestampMs; + } + + public function getStartedAtMs(): int + { + return $this->startedAtMs; + } + + public function getFinishedAtMs(): ?int + { + return $this->finishedAtMs; + } + + public function isStarted(): bool + { + return $this->started; + } + + public function isFinished(): bool + { + return $this->finished; + } + + public function isFailed(): bool + { + return $this->failed; + } + + public function getQueues(): array + { + return $this->queues; + } + + public function getReceived(): int + { + return $this->received; + } + + public function getAcknowledged(): int + { + return $this->acknowledged; + } + + public function getRejected(): int + { + return $this->rejected; + } + + public function getRequeued(): int + { + return $this->requeued; + } + + public function getMemoryUsage(): int + { + return $this->memoryUsage; + } + + public function getSystemLoad(): float + { + return $this->systemLoad; + } + + public function getErrorClass(): ?string + { + return $this->errorClass; + } + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function getErrorCode(): ?int + { + return $this->errorCode; + } + + public function getErrorFile(): ?string + { + return $this->errorFile; + } + + public function getErrorLine(): ?int + { + return $this->errorLine; + } + + public function getTrance(): ?string + { + return $this->trance; + } +} diff --git a/pkg/monitoring/DatadogStorage.php b/pkg/monitoring/DatadogStorage.php new file mode 100644 index 000000000..c10cbc671 --- /dev/null +++ b/pkg/monitoring/DatadogStorage.php @@ -0,0 +1,165 @@ +config = $this->prepareConfig($config); + + if (null === $this->datadog) { + if (true === filter_var($this->config['batched'], \FILTER_VALIDATE_BOOLEAN)) { + $this->datadog = new BatchedDogStatsd($this->config); + } else { + $this->datadog = new DogStatsd($this->config); + } + } + } + + public function pushConsumerStats(ConsumerStats $stats): void + { + $queues = $stats->getQueues(); + array_walk($queues, function (string $queue) use ($stats) { + $tags = [ + 'queue' => $queue, + 'consumerId' => $stats->getConsumerId(), + ]; + + if ($stats->getFinishedAtMs()) { + $values['finishedAtMs'] = $stats->getFinishedAtMs(); + } + + $this->datadog->gauge($this->config['metric.consumers.started'], (int) $stats->isStarted(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.finished'], (int) $stats->isFinished(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.failed'], (int) $stats->isFailed(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.received'], $stats->getReceived(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.acknowledged'], $stats->getAcknowledged(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.rejected'], $stats->getRejected(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.requeued'], $stats->getRejected(), 1, $tags); + $this->datadog->gauge($this->config['metric.consumers.memoryUsage'], $stats->getMemoryUsage(), 1, $tags); + }); + } + + public function pushSentMessageStats(SentMessageStats $stats): void + { + $tags = [ + 'destination' => $stats->getDestination(), + ]; + + $properties = $stats->getProperties(); + if (false === empty($properties[Config::TOPIC])) { + $tags['topic'] = $properties[Config::TOPIC]; + } + + if (false === empty($properties[Config::COMMAND])) { + $tags['command'] = $properties[Config::COMMAND]; + } + + $this->datadog->increment($this->config['metric.messages.sent'], 1, $tags); + } + + public function pushConsumedMessageStats(ConsumedMessageStats $stats): void + { + $tags = [ + 'queue' => $stats->getQueue(), + 'status' => $stats->getStatus(), + ]; + + if (ConsumedMessageStats::STATUS_FAILED === $stats->getStatus()) { + $this->datadog->increment($this->config['metric.messages.failed'], 1, $tags); + } + + if ($stats->isRedelivered()) { + $this->datadog->increment($this->config['metric.messages.redelivered'], 1, $tags); + } + + $runtime = $stats->getTimestampMs() - $stats->getReceivedAtMs(); + $this->datadog->histogram($this->config['metric.messages.consumed'], $runtime, 1, $tags); + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('datadog' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "datadog"', $dsn->getSchemeProtocol())); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'global_tags' => $dsn->getString('global_tags'), + 'batched' => $dsn->getString('batched'), + 'metric.messages.sent' => $dsn->getString('metric.messages.sent'), + 'metric.messages.consumed' => $dsn->getString('metric.messages.consumed'), + 'metric.messages.redelivered' => $dsn->getString('metric.messages.redelivered'), + 'metric.messages.failed' => $dsn->getString('metric.messages.failed'), + 'metric.consumers.started' => $dsn->getString('metric.consumers.started'), + 'metric.consumers.finished' => $dsn->getString('metric.consumers.finished'), + 'metric.consumers.failed' => $dsn->getString('metric.consumers.failed'), + 'metric.consumers.received' => $dsn->getString('metric.consumers.received'), + 'metric.consumers.acknowledged' => $dsn->getString('metric.consumers.acknowledged'), + 'metric.consumers.rejected' => $dsn->getString('metric.consumers.rejected'), + 'metric.consumers.requeued' => $dsn->getString('metric.consumers.requeued'), + 'metric.consumers.memoryUsage' => $dsn->getString('metric.consumers.memoryUsage'), + ]), function ($value) { + return null !== $value; + }); + } + + private function prepareConfig($config): array + { + if (empty($config)) { + $config = $this->parseDsn('datadog:'); + } elseif (\is_string($config)) { + $config = $this->parseDsn($config); + } elseif (\is_array($config)) { + $config = empty($config['dsn']) ? $config : $this->parseDsn($config['dsn']); + } elseif ($config instanceof DogStatsd) { + $this->datadog = $config; + $config = []; + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + return array_replace([ + 'host' => 'localhost', + 'port' => 8125, + 'batched' => true, + 'metric.messages.sent' => 'enqueue.messages.sent', + 'metric.messages.consumed' => 'enqueue.messages.consumed', + 'metric.messages.redelivered' => 'enqueue.messages.redelivered', + 'metric.messages.failed' => 'enqueue.messages.failed', + 'metric.consumers.started' => 'enqueue.consumers.started', + 'metric.consumers.finished' => 'enqueue.consumers.finished', + 'metric.consumers.failed' => 'enqueue.consumers.failed', + 'metric.consumers.received' => 'enqueue.consumers.received', + 'metric.consumers.acknowledged' => 'enqueue.consumers.acknowledged', + 'metric.consumers.rejected' => 'enqueue.consumers.rejected', + 'metric.consumers.requeued' => 'enqueue.consumers.requeued', + 'metric.consumers.memoryUsage' => 'enqueue.consumers.memoryUsage', + ], $config); + } +} diff --git a/pkg/monitoring/GenericStatsStorageFactory.php b/pkg/monitoring/GenericStatsStorageFactory.php new file mode 100644 index 000000000..55475f953 --- /dev/null +++ b/pkg/monitoring/GenericStatsStorageFactory.php @@ -0,0 +1,65 @@ + $config]; + } + + if (false === \is_array($config)) { + throw new \InvalidArgumentException('The config must be either array or DSN string.'); + } + + if (false === array_key_exists('dsn', $config)) { + throw new \InvalidArgumentException('The config must have dsn key set.'); + } + + $dsn = Dsn::parseFirst($config['dsn']); + + if ($storageClass = $this->findStorageClass($dsn, Resources::getKnownStorages())) { + return new $storageClass(1 === \count($config) ? $config['dsn'] : $config); + } + + throw new \LogicException(sprintf('A given scheme "%s" is not supported.', $dsn->getScheme())); + } + + private function findStorageClass(Dsn $dsn, array $factories): ?string + { + $protocol = $dsn->getSchemeProtocol(); + + if ($dsn->getSchemeExtensions()) { + foreach ($factories as $storageClass => $info) { + if (empty($info['supportedSchemeExtensions'])) { + continue; + } + + if (false === \in_array($protocol, $info['schemes'], true)) { + continue; + } + + $diff = array_diff($info['supportedSchemeExtensions'], $dsn->getSchemeExtensions()); + if (empty($diff)) { + return $storageClass; + } + } + } + + foreach ($factories as $storageClass => $info) { + if (false === \in_array($protocol, $info['schemes'], true)) { + continue; + } + + return $storageClass; + } + + return null; + } +} diff --git a/pkg/monitoring/InfluxDbStorage.php b/pkg/monitoring/InfluxDbStorage.php new file mode 100644 index 000000000..e39cccfd2 --- /dev/null +++ b/pkg/monitoring/InfluxDbStorage.php @@ -0,0 +1,264 @@ + 'influxdb://127.0.0.1:8086', + * 'host' => '127.0.0.1', + * 'port' => '8086', + * 'user' => '', + * 'password' => '', + * 'db' => 'enqueue', + * 'measurementSentMessages' => 'sent-messages', + * 'measurementConsumedMessages' => 'consumed-messages', + * 'measurementConsumers' => 'consumers', + * 'client' => null, # Client instance. Null by default. + * 'retentionPolicy' => null, + * ] + * + * or + * + * influxdb://127.0.0.1:8086?user=Jon&password=secret + * + * @param array|string|null $config + */ + public function __construct($config = 'influxdb:') + { + if (false == class_exists(Client::class)) { + throw new \LogicException('Seems client library is not installed. Please install "influxdb/influxdb-php"'); + } + + if (empty($config)) { + $config = []; + } elseif (is_string($config)) { + $config = self::parseDsn($config); + } elseif (is_array($config)) { + $config = empty($config['dsn']) ? $config : self::parseDsn($config['dsn']); + } elseif ($config instanceof Client) { + // Passing Client instead of array config is deprecated because it prevents setting any configuration values + // and causes library to use defaults. + @trigger_error( + sprintf('Passing %s as %s argument is deprecated. Pass it as "client" array property or use createWithClient instead', + Client::class, + __METHOD__ + ), \E_USER_DEPRECATED); + $this->client = $config; + $config = []; + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $config = array_replace([ + 'host' => '127.0.0.1', + 'port' => '8086', + 'user' => '', + 'password' => '', + 'db' => 'enqueue', + 'measurementSentMessages' => 'sent-messages', + 'measurementConsumedMessages' => 'consumed-messages', + 'measurementConsumers' => 'consumers', + 'client' => null, + 'retentionPolicy' => null, + ], $config); + + if (null !== $config['client']) { + if (!$config['client'] instanceof Client) { + throw new \InvalidArgumentException(sprintf('%s configuration property is expected to be an instance of %s class. %s was passed instead.', 'client', Client::class, gettype($config['client']))); + } + $this->client = $config['client']; + } + + $this->config = $config; + } + + /** + * @param string $config + */ + public static function createWithClient(Client $client, $config = 'influxdb:'): self + { + if (is_string($config)) { + $config = self::parseDsn($config); + } + $config['client'] = $client; + + return new self($config); + } + + public function pushConsumerStats(ConsumerStats $stats): void + { + $points = []; + + foreach ($stats->getQueues() as $queue) { + $tags = [ + 'queue' => $queue, + 'consumerId' => $stats->getConsumerId(), + ]; + + $values = [ + 'startedAtMs' => $stats->getStartedAtMs(), + 'started' => $stats->isStarted(), + 'finished' => $stats->isFinished(), + 'failed' => $stats->isFailed(), + 'received' => $stats->getReceived(), + 'acknowledged' => $stats->getAcknowledged(), + 'rejected' => $stats->getRejected(), + 'requeued' => $stats->getRequeued(), + 'memoryUsage' => $stats->getMemoryUsage(), + 'systemLoad' => $stats->getSystemLoad(), + ]; + + if ($stats->getFinishedAtMs()) { + $values['finishedAtMs'] = $stats->getFinishedAtMs(); + } + + $points[] = new Point($this->config['measurementConsumers'], null, $tags, $values, $stats->getTimestampMs()); + } + + $this->doWrite($points); + } + + public function pushConsumedMessageStats(ConsumedMessageStats $stats): void + { + $tags = [ + 'queue' => $stats->getQueue(), + 'status' => $stats->getStatus(), + ]; + + $properties = $stats->getProperties(); + + if (false === empty($properties[Config::TOPIC])) { + $tags['topic'] = $properties[Config::TOPIC]; + } + + if (false === empty($properties[Config::COMMAND])) { + $tags['command'] = $properties[Config::COMMAND]; + } + + $values = [ + 'receivedAt' => $stats->getReceivedAtMs(), + 'processedAt' => $stats->getTimestampMs(), + 'redelivered' => $stats->isRedelivered(), + ]; + + if (ConsumedMessageStats::STATUS_FAILED === $stats->getStatus()) { + $values['failed'] = 1; + } + + $runtime = $stats->getTimestampMs() - $stats->getReceivedAtMs(); + + $points = [ + new Point($this->config['measurementConsumedMessages'], $runtime, $tags, $values, $stats->getTimestampMs()), + ]; + + $this->doWrite($points); + } + + public function pushSentMessageStats(SentMessageStats $stats): void + { + $tags = [ + 'destination' => $stats->getDestination(), + ]; + + $properties = $stats->getProperties(); + + if (false === empty($properties[Config::TOPIC])) { + $tags['topic'] = $properties[Config::TOPIC]; + } + + if (false === empty($properties[Config::COMMAND])) { + $tags['command'] = $properties[Config::COMMAND]; + } + + $points = [ + new Point($this->config['measurementSentMessages'], 1, $tags, [], $stats->getTimestampMs()), + ]; + + $this->doWrite($points); + } + + private function doWrite(array $points): void + { + if (null === $this->client) { + $this->client = new Client( + $this->config['host'], + $this->config['port'], + $this->config['user'], + $this->config['password'] + ); + } + + if ($this->client->getDriver() instanceof QueryDriverInterface) { + if (null === $this->database) { + $this->database = $this->client->selectDB($this->config['db']); + $this->database->create(); + } + + $this->database->writePoints($points, Database::PRECISION_MILLISECONDS, $this->config['retentionPolicy']); + } else { + // Code below mirrors what `writePoints` method of Database does. + try { + $parameters = [ + 'url' => sprintf('write?db=%s&precision=%s', $this->config['db'], Database::PRECISION_MILLISECONDS), + 'database' => $this->config['db'], + 'method' => 'post', + ]; + if (null !== $this->config['retentionPolicy']) { + $parameters['url'] .= sprintf('&rp=%s', $this->config['retentionPolicy']); + } + + $this->client->write($parameters, $points); + } catch (\Exception $e) { + throw new InfluxDBException($e->getMessage(), $e->getCode()); + } + } + } + + private static function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if (false === in_array($dsn->getSchemeProtocol(), ['influxdb'], true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "influxdb"', $dsn->getSchemeProtocol())); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'user' => $dsn->getUser(), + 'password' => $dsn->getPassword(), + 'db' => $dsn->getString('db'), + 'measurementSentMessages' => $dsn->getString('measurementSentMessages'), + 'measurementConsumedMessages' => $dsn->getString('measurementConsumedMessages'), + 'measurementConsumers' => $dsn->getString('measurementConsumers'), + 'retentionPolicy' => $dsn->getString('retentionPolicy'), + ]), function ($value) { return null !== $value; }); + } +} diff --git a/pkg/monitoring/JsonSerializer.php b/pkg/monitoring/JsonSerializer.php new file mode 100644 index 000000000..8d046092a --- /dev/null +++ b/pkg/monitoring/JsonSerializer.php @@ -0,0 +1,31 @@ + $rfClass->getShortName(), + ]; + + foreach ($rfClass->getProperties() as $rfProperty) { + $rfProperty->setAccessible(true); + $data[$rfProperty->getName()] = $rfProperty->getValue($stats); + $rfProperty->setAccessible(false); + } + + $json = json_encode($data); + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return $json; + } +} diff --git a/pkg/monitoring/LICENSE b/pkg/monitoring/LICENSE new file mode 100644 index 000000000..7afbaa1ff --- /dev/null +++ b/pkg/monitoring/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Forma-Pro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/monitoring/README.md b/pkg/monitoring/README.md new file mode 100644 index 000000000..dfd33f056 --- /dev/null +++ b/pkg/monitoring/README.md @@ -0,0 +1,42 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Enqueue Monitoring + +Queue Monitoring tool. Track sent, consumed messages. Consumers performances. + +* Could be used with any message queue library. +* Could be integrated to any PHP framework +* Could send stats to any analytical platform +* Supports Datadog, InfluxDb, Grafana and WAMP out of the box. +* Provides integration for Enqueue + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/monitoring/ci.yml?branch=master)](https://github.com/php-enqueue/monitoring/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/monitoring/d/total.png)](https://packagist.org/packages/enqueue/monitoring) +[![Latest Stable Version](https://poser.pugx.org/enqueue/monitoring/version.png)](https://packagist.org/packages/enqueue/monitoring) + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/monitoring.md) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/monitoring/Resources.php b/pkg/monitoring/Resources.php new file mode 100644 index 000000000..409d9861f --- /dev/null +++ b/pkg/monitoring/Resources.php @@ -0,0 +1,55 @@ + $item) { + foreach ($item['schemes'] as $scheme) { + $schemes[$scheme] = $storageClass; + } + } + + return $schemes; + } + + public static function getKnownStorages(): array + { + if (null === self::$knownStorages) { + $map = []; + + $map[WampStorage::class] = [ + 'schemes' => ['wamp', 'ws'], + 'supportedSchemeExtensions' => [], + ]; + + $map[InfluxDbStorage::class] = [ + 'schemes' => ['influxdb'], + 'supportedSchemeExtensions' => [], + ]; + + $map[DatadogStorage::class] = [ + 'schemes' => ['datadog'], + 'supportedSchemeExtensions' => [], + ]; + + self::$knownStorages = $map; + } + + return self::$knownStorages; + } +} diff --git a/pkg/monitoring/SentMessageStats.php b/pkg/monitoring/SentMessageStats.php new file mode 100644 index 000000000..f8ddc73be --- /dev/null +++ b/pkg/monitoring/SentMessageStats.php @@ -0,0 +1,96 @@ +timestampMs = $timestampMs; + $this->destination = $destination; + $this->isTopic = $isTopic; + $this->messageId = $messageId; + $this->correlationId = $correlationId; + $this->headers = $headers; + $this->properties = $properties; + } + + public function getTimestampMs(): int + { + return $this->timestampMs; + } + + public function getDestination(): string + { + return $this->destination; + } + + public function isTopic(): bool + { + return $this->isTopic; + } + + public function getMessageId(): ?string + { + return $this->messageId; + } + + public function getCorrelationId(): ?string + { + return $this->correlationId; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getProperties(): array + { + return $this->properties; + } +} diff --git a/pkg/monitoring/Serializer.php b/pkg/monitoring/Serializer.php new file mode 100644 index 000000000..227dbc602 --- /dev/null +++ b/pkg/monitoring/Serializer.php @@ -0,0 +1,10 @@ +diUtils = DiUtils::create(self::MODULE, $name); + } + + public static function getConfiguration(string $name = 'monitoring'): ArrayNodeDefinition + { + $builder = new ArrayNodeDefinition($name); + + $builder + ->info(sprintf('The "%s" option could accept a string DSN, an array with DSN key, or null. It accept extra options. To find out what option you can set, look at stats storage constructor doc block.', $name)) + ->beforeNormalization() + ->always(function ($v) { + if (\is_array($v)) { + if (isset($v['storage_factory_class'], $v['storage_factory_service'])) { + throw new \LogicException('Both options storage_factory_class and storage_factory_service are set. Please choose one.'); + } + + return $v; + } + + if (is_string($v)) { + return ['dsn' => $v]; + } + + return $v; + }) + ->end() + ->ignoreExtraKeys(false) + ->children() + ->scalarNode('dsn') + ->cannotBeEmpty() + ->isRequired() + ->info(sprintf('The stats storage DSN. These schemes are supported: "%s".', implode('", "', array_keys(Resources::getKnownSchemes())))) + ->end() + ->scalarNode('storage_factory_service') + ->info(sprintf('The factory class should implement "%s" interface', StatsStorageFactory::class)) + ->end() + ->scalarNode('storage_factory_class') + ->info(sprintf('The factory service should be a class that implements "%s" interface', StatsStorageFactory::class)) + ->end() + ->end() + ; + + return $builder; + } + + public function buildStorage(ContainerBuilder $container, array $config): void + { + $storageId = $this->diUtils->format('storage'); + $storageFactoryId = $this->diUtils->format('storage.factory'); + + if (isset($config['storage_factory_service'])) { + $container->setAlias($storageFactoryId, $config['storage_factory_service']); + } elseif (isset($config['storage_factory_class'])) { + $container->register($storageFactoryId, $config['storage_factory_class']); + } else { + $container->register($storageFactoryId, GenericStatsStorageFactory::class); + } + + unset($config['storage_factory_service'], $config['storage_factory_class']); + + $container->register($storageId, StatsStorage::class) + ->setFactory([new Reference($storageFactoryId), 'create']) + ->addArgument($config) + ; + } + + public function buildClientExtension(ContainerBuilder $container, array $config): void + { + $container->register($this->diUtils->format('client_extension'), ClientMonitoringExtension::class) + ->addArgument($this->diUtils->reference('storage')) + ->addArgument(new Reference('logger')) + ->addTag('enqueue.client_extension', ['client' => $this->diUtils->getConfigName()]) + ; + } + + public function buildConsumerExtension(ContainerBuilder $container, array $config): void + { + $container->register($this->diUtils->format('consumer_extension'), ConsumerMonitoringExtension::class) + ->addArgument($this->diUtils->reference('storage')) + ->addTag('enqueue.consumption_extension', ['client' => $this->diUtils->getConfigName()]) + ->addTag('enqueue.transport.consumption_extension', ['transport' => $this->diUtils->getConfigName()]) + ; + } +} diff --git a/pkg/monitoring/Tests/GenericStatsStorageFactoryTest.php b/pkg/monitoring/Tests/GenericStatsStorageFactoryTest.php new file mode 100644 index 000000000..fe7c2c759 --- /dev/null +++ b/pkg/monitoring/Tests/GenericStatsStorageFactoryTest.php @@ -0,0 +1,52 @@ +assertClassImplements(StatsStorageFactory::class, GenericStatsStorageFactory::class); + } + + public function testShouldCreateInfluxDbStorage(): void + { + $storage = (new GenericStatsStorageFactory())->create('influxdb:'); + + $this->assertInstanceOf(InfluxDbStorage::class, $storage); + } + + public function testShouldCreateWampStorage(): void + { + $storage = (new GenericStatsStorageFactory())->create('wamp:'); + + $this->assertInstanceOf(WampStorage::class, $storage); + } + + public function testShouldCreateDatadogStorage(): void + { + $storage = (new GenericStatsStorageFactory())->create('datadog:'); + + $this->assertInstanceOf(DatadogStorage::class, $storage); + } + + public function testShouldThrowIfStorageIsNotSupported(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('A given scheme "unsupported" is not supported.'); + + (new GenericStatsStorageFactory())->create('unsupported:'); + } +} diff --git a/pkg/monitoring/WampStorage.php b/pkg/monitoring/WampStorage.php new file mode 100644 index 000000000..0d5ba1801 --- /dev/null +++ b/pkg/monitoring/WampStorage.php @@ -0,0 +1,211 @@ + 'wamp://127.0.0.1:9090', + * 'host' => '127.0.0.1', + * 'port' => '9090', + * 'topic' => 'stats', + * 'max_retries' => 15, + * 'initial_retry_delay' => 1.5, + * 'max_retry_delay' => 300, + * 'retry_delay_growth' => 1.5, + * ] + * + * or + * + * wamp://127.0.0.1:9090?max_retries=10 + * + * @param array|string|null $config + */ + public function __construct($config = 'wamp:') + { + if (false == class_exists(Client::class) || false == class_exists(PawlTransportProvider::class)) { + throw new \LogicException('Seems client libraries are not installed. Please install "thruway/client" and "thruway/pawl-transport"'); + } + + if (empty($config)) { + $config = $this->parseDsn('wamp:'); + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + $config = empty($config['dsn']) ? $config : $this->parseDsn($config['dsn']); + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $config = array_replace([ + 'host' => '127.0.0.1', + 'port' => '9090', + 'topic' => 'stats', + 'max_retries' => 15, + 'initial_retry_delay' => 1.5, + 'max_retry_delay' => 300, + 'retry_delay_growth' => 1.5, + ], $config); + + $this->config = $config; + + $this->serialiser = new JsonSerializer(); + } + + public function pushConsumerStats(ConsumerStats $stats): void + { + $this->push($stats); + } + + public function pushConsumedMessageStats(ConsumedMessageStats $stats): void + { + $this->push($stats); + } + + public function pushSentMessageStats(SentMessageStats $stats): void + { + $this->push($stats); + } + + private function push(Stats $stats) + { + $init = false; + $this->stats = $stats; + + if (null === $this->client) { + $init = true; + + $this->client = $this->createClient(); + $this->client->setAttemptRetry(true); + $this->client->on('open', function (ClientSession $session) { + $this->session = $session; + + $this->doSendMessageIfPossible(); + }); + + $this->client->on('close', function () { + if ($this->session === $this->client->getSession()) { + $this->session = null; + } + }); + + $this->client->on('error', function () { + if ($this->session === $this->client->getSession()) { + $this->session = null; + } + }); + + $this->client->on('do-send', function (Stats $stats) { + $onFinish = function () { + $this->client->emit('do-stop'); + }; + + $payload = $this->serialiser->toString($stats); + + $this->session->publish('stats', [$payload], [], ['acknowledge' => true]) + ->then($onFinish, $onFinish); + }); + + $this->client->on('do-stop', function () { + $this->client->getLoop()->stop(); + }); + } + + $this->client->getLoop()->futureTick(function () { + $this->doSendMessageIfPossible(); + }); + + if ($init) { + $this->client->start(false); + } + + $this->client->getLoop()->run(); + } + + private function doSendMessageIfPossible() + { + if (null === $this->session) { + return; + } + + if (null === $this->stats) { + return; + } + + $stats = $this->stats; + + $this->stats = null; + + $this->client->emit('do-send', [$stats]); + } + + private function createClient(): Client + { + $uri = sprintf('ws://%s:%s', $this->config['host'], $this->config['port']); + + $client = new Client('realm1'); + $client->addTransportProvider(new PawlTransportProvider($uri)); + $client->setReconnectOptions([ + 'max_retries' => $this->config['max_retries'], + 'initial_retry_delay' => $this->config['initial_retry_delay'], + 'max_retry_delay' => $this->config['max_retry_delay'], + 'retry_delay_growth' => $this->config['retry_delay_growth'], + ]); + + return $client; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if (false === in_array($dsn->getSchemeProtocol(), ['wamp', 'ws'], true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "wamp"', $dsn->getSchemeProtocol())); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'topic' => $dsn->getString('topic'), + 'max_retries' => $dsn->getDecimal('max_retries'), + 'initial_retry_delay' => $dsn->getFloat('initial_retry_delay'), + 'max_retry_delay' => $dsn->getDecimal('max_retry_delay'), + 'retry_delay_growth' => $dsn->getFloat('retry_delay_growth'), + ]), function ($value) { return null !== $value; }); + } +} diff --git a/pkg/monitoring/composer.json b/pkg/monitoring/composer.json new file mode 100644 index 000000000..13b57a5f2 --- /dev/null +++ b/pkg/monitoring/composer.json @@ -0,0 +1,47 @@ +{ + "name": "enqueue/monitoring", + "type": "library", + "description": "Enqueue Monitoring", + "keywords": ["messaging", "queue", "monitoring", "grafana"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "enqueue/enqueue": "^0.10", + "enqueue/dsn": "^0.10" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "influxdb/influxdb-php": "^1.14", + "datadog/php-datadogstatsd": "^1.3", + "thruway/client": "^0.5.5", + "thruway/pawl-transport": "^0.5", + "voryx/thruway-common": "^1.0.1" + }, + "suggest": { + "thruway/client": "Client for Thruway and the WAMP (Web Application Messaging Protocol).", + "thruway/pawl-transport": "Pawl WebSocket Transport for Thruway Client", + "influxdb/influxdb-php": "A PHP Client for InfluxDB, a time series database", + "datadog/php-datadogstatsd": "Datadog monitoring tool PHP integration" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Monitoring\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/monitoring/phpunit.xml.dist b/pkg/monitoring/phpunit.xml.dist new file mode 100644 index 000000000..254ab22d6 --- /dev/null +++ b/pkg/monitoring/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/null/.gitattributes b/pkg/null/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/null/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/null/.github/workflows/ci.yml b/pkg/null/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/null/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/null/.gitignore b/pkg/null/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/null/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/null/LICENSE b/pkg/null/LICENSE new file mode 100644 index 000000000..d9fa0fd46 --- /dev/null +++ b/pkg/null/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2013 Oro, Inc +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/pkg/null/NullConnectionFactory.php b/pkg/null/NullConnectionFactory.php new file mode 100644 index 000000000..b89cd5089 --- /dev/null +++ b/pkg/null/NullConnectionFactory.php @@ -0,0 +1,19 @@ +queue = $queue; + } + + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return NullMessage + */ + public function receive(int $timeout = 0): ?Message + { + return null; + } + + /** + * @return NullMessage + */ + public function receiveNoWait(): ?Message + { + return null; + } + + public function acknowledge(Message $message): void + { + } + + public function reject(Message $message, bool $requeue = false): void + { + } +} diff --git a/pkg/null/NullContext.php b/pkg/null/NullContext.php new file mode 100644 index 000000000..5f6001cab --- /dev/null +++ b/pkg/null/NullContext.php @@ -0,0 +1,86 @@ +setBody($body); + $message->setProperties($properties); + $message->setHeaders($headers); + + return $message; + } + + /** + * @return NullQueue + */ + public function createQueue(string $name): Queue + { + return new NullQueue($name); + } + + /** + * @return NullQueue + */ + public function createTemporaryQueue(): Queue + { + return $this->createQueue(uniqid('', true)); + } + + /** + * @return NullTopic + */ + public function createTopic(string $name): Topic + { + return new NullTopic($name); + } + + /** + * @return NullConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + return new NullConsumer($destination); + } + + /** + * @return NullProducer + */ + public function createProducer(): Producer + { + return new NullProducer(); + } + + /** + * @return NullSubscriptionConsumer + */ + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new NullSubscriptionConsumer(); + } + + public function purgeQueue(Queue $queue): void + { + } + + public function close(): void + { + } +} diff --git a/pkg/null/NullMessage.php b/pkg/null/NullMessage.php new file mode 100644 index 000000000..93fc57c3d --- /dev/null +++ b/pkg/null/NullMessage.php @@ -0,0 +1,150 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + + $this->redelivered = false; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $headers = $this->getHeaders(); + $headers['correlation_id'] = (string) $correlationId; + + $this->setHeaders($headers); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $headers = $this->getHeaders(); + $headers['message_id'] = (string) $messageId; + + $this->setHeaders($headers); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $headers = $this->getHeaders(); + $headers['timestamp'] = (int) $timestamp; + + $this->setHeaders($headers); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } +} diff --git a/pkg/null/NullProducer.php b/pkg/null/NullProducer.php new file mode 100644 index 000000000..1349de9ba --- /dev/null +++ b/pkg/null/NullProducer.php @@ -0,0 +1,67 @@ +deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return NullProducer + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return NullProducer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/pkg/null/NullQueue.php b/pkg/null/NullQueue.php new file mode 100644 index 000000000..543111e67 --- /dev/null +++ b/pkg/null/NullQueue.php @@ -0,0 +1,25 @@ +name = $name; + } + + public function getQueueName(): string + { + return $this->name; + } +} diff --git a/pkg/null/NullSubscriptionConsumer.php b/pkg/null/NullSubscriptionConsumer.php new file mode 100644 index 000000000..1e9591666 --- /dev/null +++ b/pkg/null/NullSubscriptionConsumer.php @@ -0,0 +1,27 @@ +name = $name; + } + + public function getTopicName(): string + { + return $this->name; + } +} diff --git a/pkg/null/README.md b/pkg/null/README.md new file mode 100644 index 000000000..7d78ae0d6 --- /dev/null +++ b/pkg/null/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Enqueue Null Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/null/ci.yml?branch=master)](https://github.com/php-enqueue/null/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/null/d/total.png)](https://packagist.org/packages/enqueue/null) +[![Latest Stable Version](https://poser.pugx.org/enqueue/null/version.png)](https://packagist.org/packages/enqueue/null) + +This is an implementation of Queue Interop specification. It does not send messages any where and could be used as mock. Suitable in tests. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/null/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/null/Tests/NullConnectionFactoryTest.php b/pkg/null/Tests/NullConnectionFactoryTest.php new file mode 100644 index 000000000..bbf377e85 --- /dev/null +++ b/pkg/null/Tests/NullConnectionFactoryTest.php @@ -0,0 +1,28 @@ +assertClassImplements(ConnectionFactory::class, NullConnectionFactory::class); + } + + public function testShouldReturnNullContextOnCreateContextCall() + { + $factory = new NullConnectionFactory(); + + $context = $factory->createContext(); + + $this->assertInstanceOf(NullContext::class, $context); + } +} diff --git a/pkg/enqueue/Tests/Transport/Null/NullConsumerTest.php b/pkg/null/Tests/NullConsumerTest.php similarity index 76% rename from pkg/enqueue/Tests/Transport/Null/NullConsumerTest.php rename to pkg/null/Tests/NullConsumerTest.php index 92a644e7b..f4de53311 100644 --- a/pkg/enqueue/Tests/Transport/Null/NullConsumerTest.php +++ b/pkg/null/Tests/NullConsumerTest.php @@ -1,14 +1,15 @@ assertClassImplements(Consumer::class, NullConsumer::class); } - public function testCouldBeConstructedWithQueueAsArgument() - { - new NullConsumer(new NullQueue('aName')); - } - public function testShouldAlwaysReturnNullOnReceive() { $consumer = new NullConsumer(new NullQueue('theQueueName')); @@ -40,6 +36,9 @@ public function testShouldAlwaysReturnNullOnReceiveNoWait() $this->assertNull($consumer->receiveNoWait()); } + /** + * @doesNotPerformAssertions + */ public function testShouldDoNothingOnAcknowledge() { $consumer = new NullConsumer(new NullQueue('theQueueName')); @@ -47,6 +46,9 @@ public function testShouldDoNothingOnAcknowledge() $consumer->acknowledge(new NullMessage()); } + /** + * @doesNotPerformAssertions + */ public function testShouldDoNothingOnReject() { $consumer = new NullConsumer(new NullQueue('theQueueName')); diff --git a/pkg/null/Tests/NullContextTest.php b/pkg/null/Tests/NullContextTest.php new file mode 100644 index 000000000..f0da566d2 --- /dev/null +++ b/pkg/null/Tests/NullContextTest.php @@ -0,0 +1,100 @@ +assertClassImplements(Context::class, NullContext::class); + } + + public function testShouldAllowCreateMessageWithoutAnyArguments() + { + $context = new NullContext(); + + $message = $context->createMessage(); + + $this->assertInstanceOf(NullMessage::class, $message); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getHeaders()); + $this->assertSame([], $message->getProperties()); + } + + public function testShouldAllowCreateCustomMessage() + { + $context = new NullContext(); + + $message = $context->createMessage('theBody', ['theProperty'], ['theHeader']); + + $this->assertInstanceOf(NullMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['theProperty'], $message->getProperties()); + $this->assertSame(['theHeader'], $message->getHeaders()); + } + + public function testShouldAllowCreateQueue() + { + $context = new NullContext(); + + $queue = $context->createQueue('aName'); + + $this->assertInstanceOf(NullQueue::class, $queue); + } + + public function testShouldAllowCreateTopic() + { + $context = new NullContext(); + + $topic = $context->createTopic('aName'); + + $this->assertInstanceOf(NullTopic::class, $topic); + } + + public function testShouldAllowCreateConsumerForGivenQueue() + { + $context = new NullContext(); + + $queue = new NullQueue('aName'); + + $consumer = $context->createConsumer($queue); + + $this->assertInstanceOf(NullConsumer::class, $consumer); + } + + public function testShouldAllowCreateProducer() + { + $context = new NullContext(); + + $producer = $context->createProducer(); + + $this->assertInstanceOf(NullProducer::class, $producer); + } + + public function testShouldCreateTemporaryQueueWithUniqueName() + { + $context = new NullContext(); + + $firstTmpQueue = $context->createTemporaryQueue(); + $secondTmpQueue = $context->createTemporaryQueue(); + + $this->assertInstanceOf(NullQueue::class, $firstTmpQueue); + $this->assertInstanceOf(NullQueue::class, $secondTmpQueue); + + $this->assertNotEquals($firstTmpQueue->getQueueName(), $secondTmpQueue->getQueueName()); + } +} diff --git a/pkg/null/Tests/NullMessageTest.php b/pkg/null/Tests/NullMessageTest.php new file mode 100644 index 000000000..f8a7090bb --- /dev/null +++ b/pkg/null/Tests/NullMessageTest.php @@ -0,0 +1,36 @@ +assertClassImplements(Message::class, NullMessage::class); + } + + public function testCouldBeConstructedWithoutAnyArguments() + { + $message = new NullMessage(); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new NullMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } +} diff --git a/pkg/null/Tests/NullProducerTest.php b/pkg/null/Tests/NullProducerTest.php new file mode 100644 index 000000000..140d683ba --- /dev/null +++ b/pkg/null/Tests/NullProducerTest.php @@ -0,0 +1,30 @@ +assertClassImplements(Producer::class, NullProducer::class); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldDoNothingOnSend() + { + $producer = new NullProducer(); + + $producer->send(new NullTopic('aName'), new NullMessage()); + } +} diff --git a/pkg/null/Tests/NullQueueTest.php b/pkg/null/Tests/NullQueueTest.php new file mode 100644 index 000000000..cb29ca180 --- /dev/null +++ b/pkg/null/Tests/NullQueueTest.php @@ -0,0 +1,25 @@ +assertClassImplements(Queue::class, NullQueue::class); + } + + public function testShouldAllowGetNameSetInConstructor() + { + $queue = new NullQueue('theName'); + + $this->assertEquals('theName', $queue->getQueueName()); + } +} diff --git a/pkg/null/Tests/NullTopicTest.php b/pkg/null/Tests/NullTopicTest.php new file mode 100644 index 000000000..27c4b58de --- /dev/null +++ b/pkg/null/Tests/NullTopicTest.php @@ -0,0 +1,25 @@ +assertClassImplements(Topic::class, NullTopic::class); + } + + public function testShouldAllowGetNameSetInConstructor() + { + $topic = new NullTopic('theName'); + + $this->assertEquals('theName', $topic->getTopicName()); + } +} diff --git a/pkg/null/Tests/Spec/NullMessageTest.php b/pkg/null/Tests/Spec/NullMessageTest.php new file mode 100644 index 000000000..6bacc9294 --- /dev/null +++ b/pkg/null/Tests/Spec/NullMessageTest.php @@ -0,0 +1,14 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Resources + ./Tests + + + + diff --git a/pkg/pheanstalk/.gitattributes b/pkg/pheanstalk/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/pheanstalk/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/pheanstalk/.github/workflows/ci.yml b/pkg/pheanstalk/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/pheanstalk/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/pheanstalk/LICENSE b/pkg/pheanstalk/LICENSE new file mode 100644 index 000000000..d9736f8bf --- /dev/null +++ b/pkg/pheanstalk/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/pheanstalk/PheanstalkConnectionFactory.php b/pkg/pheanstalk/PheanstalkConnectionFactory.php new file mode 100644 index 000000000..d3cd5f0c0 --- /dev/null +++ b/pkg/pheanstalk/PheanstalkConnectionFactory.php @@ -0,0 +1,117 @@ + 'localhost', + * 'port' => 11300, + * 'timeout' => null, + * 'persisted' => true, + * ] + * + * or + * + * beanstalk: - connects to localhost:11300 + * beanstalk://host:port + * + * @param array|string $config + */ + public function __construct($config = 'beanstalk:') + { + if (empty($config) || 'beanstalk:' === $config) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return PheanstalkContext + */ + public function createContext(): Context + { + return new PheanstalkContext($this->establishConnection()); + } + + private function establishConnection(): Pheanstalk + { + if (false == $this->connection) { + $this->connection = new Pheanstalk( + $this->config['host'], + $this->config['port'], + $this->config['timeout'], + $this->config['persisted'] + ); + } + + return $this->connection; + } + + private function parseDsn(string $dsn): array + { + $dsnConfig = parse_url($dsn); + if (false === $dsnConfig) { + throw new \LogicException(sprintf('Failed to parse DSN "%s"', $dsn)); + } + + $dsnConfig = array_replace([ + 'scheme' => null, + 'host' => null, + 'port' => null, + 'user' => null, + 'pass' => null, + 'path' => null, + 'query' => null, + ], $dsnConfig); + + if ('beanstalk' !== $dsnConfig['scheme']) { + throw new \LogicException(sprintf('The given DSN scheme "%s" is not supported. Could be "beanstalk" only.', $dsnConfig['scheme'])); + } + + $query = []; + if ($dsnConfig['query']) { + parse_str($dsnConfig['query'], $query); + } + + return array_replace($query, [ + 'port' => $dsnConfig['port'], + 'host' => $dsnConfig['host'], + ]); + } + + private function defaultConfig(): array + { + return [ + 'host' => 'localhost', + 'port' => Pheanstalk::DEFAULT_PORT, + 'timeout' => null, + 'persisted' => true, + ]; + } +} diff --git a/pkg/pheanstalk/PheanstalkConsumer.php b/pkg/pheanstalk/PheanstalkConsumer.php new file mode 100644 index 000000000..3fb217683 --- /dev/null +++ b/pkg/pheanstalk/PheanstalkConsumer.php @@ -0,0 +1,119 @@ +destination = $destination; + $this->pheanstalk = $pheanstalk; + } + + /** + * @return PheanstalkDestination + */ + public function getQueue(): Queue + { + return $this->destination; + } + + /** + * @return PheanstalkMessage + */ + public function receive(int $timeout = 0): ?Message + { + if (0 === $timeout) { + while (true) { + if ($job = $this->pheanstalk->reserveFromTube($this->destination->getName(), 5)) { + return $this->convertJobToMessage($job); + } + } + } else { + if ($job = $this->pheanstalk->reserveFromTube($this->destination->getName(), $timeout / 1000)) { + return $this->convertJobToMessage($job); + } + } + + return null; + } + + /** + * @return PheanstalkMessage + */ + public function receiveNoWait(): ?Message + { + if ($job = $this->pheanstalk->reserveFromTube($this->destination->getName(), 0)) { + return $this->convertJobToMessage($job); + } + + return null; + } + + /** + * @param PheanstalkMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, PheanstalkMessage::class); + + if (false == $message->getJob()) { + throw new \LogicException('The message could not be acknowledged because it does not have job set.'); + } + + $this->pheanstalk->delete($message->getJob()); + } + + /** + * @param PheanstalkMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, PheanstalkMessage::class); + + if (false == $message->getJob()) { + $state = $requeue ? 'requeued' : 'rejected'; + throw new \LogicException(sprintf('The message could not be %s because it does not have job set.', $state)); + } + + if ($requeue) { + $this->pheanstalk->release($message->getJob(), $message->getPriority(), $message->getDelay()); + + return; + } + + $this->acknowledge($message); + } + + private function convertJobToMessage(Job $job): PheanstalkMessage + { + $stats = $this->pheanstalk->statsJob($job); + + $message = PheanstalkMessage::jsonUnserialize($job->getData()); + if (isset($stats['reserves'])) { + $message->setRedelivered($stats['reserves'] > 1); + } + $message->setJob($job); + + return $message; + } +} diff --git a/pkg/pheanstalk/PheanstalkContext.php b/pkg/pheanstalk/PheanstalkContext.php new file mode 100644 index 000000000..3e2fe834d --- /dev/null +++ b/pkg/pheanstalk/PheanstalkContext.php @@ -0,0 +1,101 @@ +pheanstalk = $pheanstalk; + } + + /** + * @return PheanstalkMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new PheanstalkMessage($body, $properties, $headers); + } + + /** + * @return PheanstalkDestination + */ + public function createTopic(string $topicName): Topic + { + return new PheanstalkDestination($topicName); + } + + /** + * @return PheanstalkDestination + */ + public function createQueue(string $queueName): Queue + { + return new PheanstalkDestination($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return PheanstalkProducer + */ + public function createProducer(): Producer + { + return new PheanstalkProducer($this->pheanstalk); + } + + /** + * @param PheanstalkDestination $destination + * + * @return PheanstalkConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, PheanstalkDestination::class); + + return new PheanstalkConsumer($destination, $this->pheanstalk); + } + + public function close(): void + { + $this->pheanstalk->getConnection()->disconnect(); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function purgeQueue(Queue $queue): void + { + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPheanstalk(): Pheanstalk + { + return $this->pheanstalk; + } +} diff --git a/pkg/pheanstalk/PheanstalkDestination.php b/pkg/pheanstalk/PheanstalkDestination.php new file mode 100644 index 000000000..76565b1ae --- /dev/null +++ b/pkg/pheanstalk/PheanstalkDestination.php @@ -0,0 +1,36 @@ +destinationName = $destinationName; + } + + public function getName(): string + { + return $this->destinationName; + } + + public function getQueueName(): string + { + return $this->destinationName; + } + + public function getTopicName(): string + { + return $this->destinationName; + } +} diff --git a/pkg/pheanstalk/PheanstalkMessage.php b/pkg/pheanstalk/PheanstalkMessage.php new file mode 100644 index 000000000..5bff1a7a6 --- /dev/null +++ b/pkg/pheanstalk/PheanstalkMessage.php @@ -0,0 +1,206 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', (string) $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', (string) $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function setTimeToRun(int $time) + { + $this->setHeader('ttr', $time); + } + + public function getTimeToRun(): int + { + return $this->getHeader('ttr', Pheanstalk::DEFAULT_TTR); + } + + public function setPriority(int $priority): void + { + $this->setHeader('priority', $priority); + } + + public function getPriority(): int + { + return $this->getHeader('priority', Pheanstalk::DEFAULT_PRIORITY); + } + + public function setDelay(int $delay): void + { + $this->setHeader('delay', $delay); + } + + public function getDelay(): int + { + return $this->getHeader('delay', Pheanstalk::DEFAULT_DELAY); + } + + public function jsonSerialize(): array + { + return [ + 'body' => $this->getBody(), + 'properties' => $this->getProperties(), + 'headers' => $this->getHeaders(), + ]; + } + + public static function jsonUnserialize(string $json): self + { + $data = json_decode($json, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new self($data['body'], $data['properties'], $data['headers']); + } + + public function getJob(): ?Job + { + return $this->job; + } + + public function setJob(?Job $job = null): void + { + $this->job = $job; + } +} diff --git a/pkg/pheanstalk/PheanstalkProducer.php b/pkg/pheanstalk/PheanstalkProducer.php new file mode 100644 index 000000000..0c8ffd8ff --- /dev/null +++ b/pkg/pheanstalk/PheanstalkProducer.php @@ -0,0 +1,117 @@ +pheanstalk = $pheanstalk; + } + + /** + * @param PheanstalkDestination $destination + * @param PheanstalkMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, PheanstalkDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, PheanstalkMessage::class); + + $rawMessage = json_encode($message); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('Could not encode value into json. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + if (null !== $this->priority && null === $message->getHeader('priority')) { + $message->setPriority($this->priority); + } + if (null !== $this->deliveryDelay && null === $message->getHeader('delay')) { + $message->setDelay($this->deliveryDelay / 1000); + } + if (null !== $this->timeToLive && null === $message->getHeader('ttr')) { + $message->setTimeToRun($this->timeToLive / 1000); + } + + $this->pheanstalk->useTube($destination->getName())->put( + $rawMessage, + $message->getPriority(), + $message->getDelay(), + $message->getTimeToRun() + ); + } + + /** + * @return PheanstalkProducer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return PheanstalkProducer + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return PheanstalkProducer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/pkg/pheanstalk/README.md b/pkg/pheanstalk/README.md new file mode 100644 index 000000000..6461741cf --- /dev/null +++ b/pkg/pheanstalk/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Beanstalk Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/pheanstalk/ci.yml?branch=master)](https://github.com/php-enqueue/pheanstalk/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/pheanstalk/d/total.png)](https://packagist.org/packages/enqueue/pheanstalk) +[![Latest Stable Version](https://poser.pugx.org/enqueue/pheanstalk/version.png)](https://packagist.org/packages/enqueue/pheanstalk) + +This is an implementation of the queue specification. It allows you to send and consume message from Beanstalkd broker. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/pheanstalk/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/pheanstalk/Tests/PheanstalkConnectionFactoryConfigTest.php b/pkg/pheanstalk/Tests/PheanstalkConnectionFactoryConfigTest.php new file mode 100644 index 000000000..a7bc7fc34 --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkConnectionFactoryConfigTest.php @@ -0,0 +1,134 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new PheanstalkConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotBeanstalkAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN scheme "http" is not supported. Could be "beanstalk" only.'); + + new PheanstalkConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to parse DSN "beanstalk://:@/"'); + + new PheanstalkConnectionFactory('beanstalk://:@/'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new PheanstalkConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'host' => 'localhost', + 'port' => 11300, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + 'beanstalk:', + [ + 'host' => 'localhost', + 'port' => 11300, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + [], + [ + 'host' => 'localhost', + 'port' => 11300, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + 'beanstalk://theHost:1234', + [ + 'host' => 'theHost', + 'port' => 1234, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + ['host' => 'theHost', 'port' => 1234], + [ + 'host' => 'theHost', + 'port' => 1234, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + ['host' => 'theHost'], + [ + 'host' => 'theHost', + 'port' => 11300, + 'timeout' => null, + 'persisted' => true, + ], + ]; + + yield [ + ['host' => 'theHost', 'timeout' => 123], + [ + 'host' => 'theHost', + 'port' => 11300, + 'timeout' => 123, + 'persisted' => true, + ], + ]; + + yield [ + 'beanstalk://theHost:1234?timeout=123&persisted=1', + [ + 'host' => 'theHost', + 'port' => 1234, + 'timeout' => 123, + 'persisted' => 1, + ], + ]; + } +} diff --git a/pkg/pheanstalk/Tests/PheanstalkConsumerTest.php b/pkg/pheanstalk/Tests/PheanstalkConsumerTest.php new file mode 100644 index 000000000..c79b20bbd --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkConsumerTest.php @@ -0,0 +1,223 @@ +createPheanstalkMock() + ); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testShouldReceiveFromQueueAndReturnNullIfNoMessageInQueue() + { + $destination = new PheanstalkDestination('theQueueName'); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('reserveFromTube') + ->with('theQueueName', 1) + ->willReturn(null) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $this->assertNull($consumer->receive(1000)); + } + + public function testShouldReceiveFromQueueAndReturnMessageIfMessageInQueue() + { + $destination = new PheanstalkDestination('theQueueName'); + $message = new PheanstalkMessage('theBody', ['foo' => 'fooVal'], ['bar' => 'barVal']); + + $job = new Job('theJobId', json_encode($message)); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('reserveFromTube') + ->with('theQueueName', 1) + ->willReturn($job) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $actualMessage = $consumer->receive(1000); + + $this->assertSame('theBody', $actualMessage->getBody()); + $this->assertSame(['foo' => 'fooVal'], $actualMessage->getProperties()); + $this->assertSame(['bar' => 'barVal'], $actualMessage->getHeaders()); + $this->assertSame($job, $actualMessage->getJob()); + } + + public function testShouldReceiveNoWaitFromQueueAndReturnNullIfNoMessageInQueue() + { + $destination = new PheanstalkDestination('theQueueName'); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('reserveFromTube') + ->with('theQueueName', 0) + ->willReturn(null) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $this->assertNull($consumer->receiveNoWait()); + } + + public function testShouldReceiveNoWaitFromQueueAndReturnMessageIfMessageInQueue() + { + $destination = new PheanstalkDestination('theQueueName'); + $message = new PheanstalkMessage('theBody', ['foo' => 'fooVal'], ['bar' => 'barVal']); + + $job = new Job('theJobId', json_encode($message)); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('reserveFromTube') + ->with('theQueueName', 0) + ->willReturn($job) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $actualMessage = $consumer->receiveNoWait(); + + $this->assertSame('theBody', $actualMessage->getBody()); + $this->assertSame(['foo' => 'fooVal'], $actualMessage->getProperties()); + $this->assertSame(['bar' => 'barVal'], $actualMessage->getHeaders()); + $this->assertSame($job, $actualMessage->getJob()); + } + + public function testShouldAcknowledgeMessage() + { + $destination = new PheanstalkDestination('theQueueName'); + $message = new PheanstalkMessage(); + + $job = new Job('theJobId', json_encode($message)); + $message->setJob($job); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('delete') + ->with($job) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $consumer->acknowledge($message); + } + + public function testAcknowledgeShouldThrowExceptionIfMessageHasNoJob() + { + $destination = new PheanstalkDestination('theQueueName'); + $pheanstalk = $this->createPheanstalkMock(); + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message could not be acknowledged because it does not have job set.'); + + $consumer->acknowledge(new PheanstalkMessage()); + } + + public function testShouldRejectMessage() + { + $destination = new PheanstalkDestination('theQueueName'); + $message = new PheanstalkMessage(); + + $job = new Job('theJobId', json_encode($message)); + $message->setJob($job); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('delete') + ->with($job) + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $consumer->reject($message); + } + + public function testRejectShouldThrowExceptionIfMessageHasNoJob() + { + $destination = new PheanstalkDestination('theQueueName'); + $pheanstalk = $this->createPheanstalkMock(); + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message could not be rejected because it does not have job set.'); + + $consumer->reject(new PheanstalkMessage()); + } + + public function testShouldRequeueMessage() + { + $destination = new PheanstalkDestination('theQueueName'); + $message = new PheanstalkMessage(); + + $job = new Job('theJobId', json_encode($message)); + $message->setJob($job); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('release') + ->with($job, Pheanstalk::DEFAULT_PRIORITY, Pheanstalk::DEFAULT_DELAY) + ; + $pheanstalk + ->expects($this->never()) + ->method('delete') + ; + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $consumer->reject($message, true); + } + + public function testRequeueShouldThrowExceptionIfMessageHasNoJob() + { + $destination = new PheanstalkDestination('theQueueName'); + $pheanstalk = $this->createPheanstalkMock(); + + $consumer = new PheanstalkConsumer($destination, $pheanstalk); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The message could not be requeued because it does not have job set.'); + + $consumer->reject(new PheanstalkMessage(), true); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Pheanstalk + */ + private function createPheanstalkMock() + { + return $this->createMock(Pheanstalk::class); + } +} diff --git a/pkg/pheanstalk/Tests/PheanstalkContextTest.php b/pkg/pheanstalk/Tests/PheanstalkContextTest.php new file mode 100644 index 000000000..3b7bfbeb7 --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkContextTest.php @@ -0,0 +1,77 @@ +assertClassImplements(Context::class, PheanstalkContext::class); + } + + public function testThrowNotImplementedOnCreateTemporaryQueue() + { + $context = new PheanstalkContext($this->createPheanstalkMock()); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + public function testThrowInvalidDestinationIfInvalidDestinationGivenOnCreateConsumer() + { + $context = new PheanstalkContext($this->createPheanstalkMock()); + + $this->expectException(InvalidDestinationException::class); + $context->createConsumer(new NullQueue('aQueue')); + } + + public function testShouldAllowGetPheanstalkSetInConstructor() + { + $pheanstalk = $this->createPheanstalkMock(); + + $context = new PheanstalkContext($pheanstalk); + + $this->assertSame($pheanstalk, $context->getPheanstalk()); + } + + public function testShouldDoConnectionDisconnectOnContextClose() + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('disconnect') + ; + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('getConnection') + ->willReturn($connection) + ; + + $context = new PheanstalkContext($pheanstalk); + + $context->close(); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Pheanstalk + */ + private function createPheanstalkMock() + { + return $this->createMock(Pheanstalk::class); + } +} diff --git a/pkg/pheanstalk/Tests/PheanstalkDestinationTest.php b/pkg/pheanstalk/Tests/PheanstalkDestinationTest.php new file mode 100644 index 000000000..d390f4715 --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkDestinationTest.php @@ -0,0 +1,31 @@ +assertClassImplements(Queue::class, PheanstalkDestination::class); + } + + public function testShouldImplementTopicInterface() + { + $this->assertClassImplements(Topic::class, PheanstalkDestination::class); + } + + public function testShouldAllowGetNameSetInConstructor() + { + $destination = new PheanstalkDestination('theDestinationName'); + + $this->assertSame('theDestinationName', $destination->getName()); + } +} diff --git a/pkg/pheanstalk/Tests/PheanstalkMessageTest.php b/pkg/pheanstalk/Tests/PheanstalkMessageTest.php new file mode 100644 index 000000000..183cbb435 --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkMessageTest.php @@ -0,0 +1,23 @@ +setJob($job); + + $this->assertSame($job, $message->getJob()); + } +} diff --git a/pkg/pheanstalk/Tests/PheanstalkProducerTest.php b/pkg/pheanstalk/Tests/PheanstalkProducerTest.php new file mode 100644 index 000000000..b9a69176c --- /dev/null +++ b/pkg/pheanstalk/Tests/PheanstalkProducerTest.php @@ -0,0 +1,220 @@ +createPheanstalkMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Pheanstalk\PheanstalkDestination but got Enqueue\Null\NullQueue.'); + $producer->send(new NullQueue('aQueue'), new PheanstalkMessage()); + } + + public function testThrowIfMessageInvalid() + { + $producer = new PheanstalkProducer($this->createPheanstalkMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Pheanstalk\PheanstalkMessage but it is Enqueue\Null\NullMessage.'); + $producer->send(new PheanstalkDestination('aQueue'), new NullMessage()); + } + + public function testShouldJsonEncodeMessageAndPutToExpectedTube() + { + $message = new PheanstalkMessage('theBody', ['foo' => 'fooVal'], ['bar' => 'barVal']); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":{"foo":"fooVal"},"headers":{"bar":"barVal"}}') + ; + + $producer = new PheanstalkProducer($pheanstalk); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + public function testMessagePriorityPrecedesPriority() + { + $message = new PheanstalkMessage('theBody'); + $message->setPriority(100); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":[],"headers":{"priority":100}}', 100, Pheanstalk::DEFAULT_DELAY, Pheanstalk::DEFAULT_TTR) + ; + + $producer = new PheanstalkProducer($pheanstalk); + $producer->setPriority(50); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + public function testAccessDeliveryDelayAsMilliseconds() + { + $producer = new PheanstalkProducer($this->createPheanstalkMock()); + $producer->setDeliveryDelay(5000); + + $this->assertEquals(5000, $producer->getDeliveryDelay()); + } + + public function testDeliveryDelayResolvesToSeconds() + { + $message = new PheanstalkMessage('theBody'); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":[],"headers":[]}', Pheanstalk::DEFAULT_PRIORITY, 5, Pheanstalk::DEFAULT_TTR) + ; + + $producer = new PheanstalkProducer($pheanstalk); + $producer->setDeliveryDelay(5000); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + public function testMessageDelayPrecedesDeliveryDelay() + { + $message = new PheanstalkMessage('theBody'); + $message->setDelay(25); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":[],"headers":{"delay":25}}', Pheanstalk::DEFAULT_PRIORITY, 25, Pheanstalk::DEFAULT_TTR) + ; + + $producer = new PheanstalkProducer($pheanstalk); + $producer->setDeliveryDelay(1000); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + public function testAccessTimeToLiveAsMilliseconds() + { + $producer = new PheanstalkProducer($this->createPheanstalkMock()); + $producer->setTimeToLive(5000); + + $this->assertEquals(5000, $producer->getTimeToLive()); + } + + public function testTimeToLiveResolvesToSeconds() + { + $message = new PheanstalkMessage('theBody'); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":[],"headers":[]}', Pheanstalk::DEFAULT_PRIORITY, Pheanstalk::DEFAULT_DELAY, 5) + ; + + $producer = new PheanstalkProducer($pheanstalk); + $producer->setTimeToLive(5000); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + public function testMessageTimeToRunPrecedesTimeToLive() + { + $message = new PheanstalkMessage('theBody'); + $message->setTimeToRun(25); + + $pheanstalk = $this->createPheanstalkMock(); + $pheanstalk + ->expects($this->once()) + ->method('useTube') + ->with('theQueueName') + ->willReturnSelf() + ; + $pheanstalk + ->expects($this->once()) + ->method('put') + ->with('{"body":"theBody","properties":[],"headers":{"ttr":25}}', Pheanstalk::DEFAULT_PRIORITY, Pheanstalk::DEFAULT_DELAY, 25) + ; + + $producer = new PheanstalkProducer($pheanstalk); + $producer->setTimeToLive(1000); + + $producer->send( + new PheanstalkDestination('theQueueName'), + $message + ); + } + + /** + * @return MockObject|Pheanstalk + */ + private function createPheanstalkMock() + { + return $this->createMock(Pheanstalk::class); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkConnectionFactoryTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkConnectionFactoryTest.php new file mode 100644 index 000000000..4d9148447 --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkConnectionFactoryTest.php @@ -0,0 +1,14 @@ +createMock(Pheanstalk::class)); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkMessageTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkMessageTest.php new file mode 100644 index 000000000..692e0db54 --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkMessageTest.php @@ -0,0 +1,14 @@ +createContext(); + } + + /** + * @param string $queueName + * + * @return Queue + */ + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.time()); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkSendToAndReceiveNoWaitFromQueueTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..de464e5bf --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,25 @@ +createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.time()); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveFromQueueTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..4c30d7796 --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,37 @@ +time = time(); + } + + protected function createContext() + { + $factory = new PheanstalkConnectionFactory(getenv('BEANSTALKD_DSN')); + + return $factory->createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.$this->time); + } + + protected function createTopic(Context $context, $topicName) + { + return $context->createTopic($topicName.$this->time); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..58e8b71f4 --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,37 @@ +time = time(); + } + + protected function createContext() + { + $factory = new PheanstalkConnectionFactory(getenv('BEANSTALKD_DSN')); + + return $factory->createContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $context->createQueue($queueName.$this->time); + } + + protected function createTopic(Context $context, $topicName) + { + return $context->createTopic($topicName.$this->time); + } +} diff --git a/pkg/pheanstalk/Tests/Spec/PheanstalkTopicTest.php b/pkg/pheanstalk/Tests/Spec/PheanstalkTopicTest.php new file mode 100644 index 000000000..4b0028261 --- /dev/null +++ b/pkg/pheanstalk/Tests/Spec/PheanstalkTopicTest.php @@ -0,0 +1,14 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/psr-queue/.travis.yml b/pkg/psr-queue/.travis.yml deleted file mode 100644 index 42374ddc7..000000000 --- a/pkg/psr-queue/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer self-update - - composer install --prefer-source - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/psr-queue/ConnectionFactory.php b/pkg/psr-queue/ConnectionFactory.php deleted file mode 100644 index 5c7a2b0be..000000000 --- a/pkg/psr-queue/ConnectionFactory.php +++ /dev/null @@ -1,11 +0,0 @@ - value, ...] - */ - public function getProperties(); - - /** - * @param string $name - * @param mixed $value - */ - public function setProperty($name, $value); - - /** - * @param string $name - * @param mixed $default - * - * @return mixed - */ - public function getProperty($name, $default = null); - - /** - * @param array $headers - */ - public function setHeaders(array $headers); - - /** - * @return array [name => value, ...] - */ - public function getHeaders(); - - /** - * @param string $name - * @param mixed $value - */ - public function setHeader($name, $value); - - /** - * @param string $name - * @param mixed $default - * - * @return mixed - */ - public function getHeader($name, $default = null); - - /** - * @param bool $redelivered - */ - public function setRedelivered($redelivered); - - /** - * Gets an indication of whether this message is being redelivered. - * The message is considered as redelivered, - * when it was sent by a broker to consumer but consumer does not ACK or REJECT it. - * The broker brings the message back to the queue and mark it as redelivered. - * - * @return bool - */ - public function isRedelivered(); - - /** - * Sets the correlation ID for the message. - * A client can use the correlation header field to link one message with another. - * A typical use is to link a response message with its request message. - * - * @param string $correlationId the message ID of a message being referred to - * - * @throws Exception if the provider fails to set the correlation ID due to some internal error - */ - public function setCorrelationId($correlationId); - - /** - * Gets the correlation ID for the message. - * This method is used to return correlation ID values that are either provider-specific message IDs - * or application-specific String values. - * - * @throws Exception if the provider fails to get the correlation ID due to some internal error - * - * @return string - */ - public function getCorrelationId(); - - /** - * Sets the message ID. - * Providers set this field when a message is sent. - * This method can be used to change the value for a message that has been received. - * - * @param string $messageId the ID of the message - * - * @throws Exception if the provider fails to set the message ID due to some internal error - */ - public function setMessageId($messageId); - - /** - * Gets the message Id. - * The MessageId header field contains a value that uniquely identifies each message sent by a provider. - * - * When a message is sent, MessageId can be ignored. - * - * @throws Exception if the provider fails to get the message ID due to some internal error - * - * @return string - */ - public function getMessageId(); - - /** - * Gets the message timestamp. - * The timestamp header field contains the time a message was handed off to a provider to be sent. - * It is not the time the message was actually transmitted, - * because the actual send may occur later due to transactions or other client-side queueing of messages. - * - * @return int - */ - public function getTimestamp(); - - /** - * Sets the message timestamp. - * Providers set this field when a message is sent. - * This method can be used to change the value for a message that has been received. - * - * @param int $timestamp - * - * @throws Exception if the provider fails to set the timestamp due to some internal error - */ - public function setTimestamp($timestamp); - - /** - * Sets the destination to which a reply to this message should be sent. - * The ReplyTo header field contains the destination where a reply to the current message should be sent. If it is null, no reply is expected. - * The destination may be a Queue only. A topic is not supported at the moment. - * Messages sent with a null ReplyTo value may be a notification of some event, or they may just be some data the sender thinks is of interest. - * Messages with a ReplyTo value typically expect a response. - * A response is optional; it is up to the client to decide. These messages are called requests. - * A message sent in response to a request is called a reply. - * In some cases a client may wish to match a request it sent earlier with a reply it has just received. - * The client can use the CorrelationID header field for this purpose. - * - * @param string|null $replyTo - */ - public function setReplyTo($replyTo); - - /** - * Gets the destination to which a reply to this message should be sent. - * - * @return string|null - */ - public function getReplyTo(); -} diff --git a/pkg/psr-queue/Processor.php b/pkg/psr-queue/Processor.php deleted file mode 100644 index 66bb42475..000000000 --- a/pkg/psr-queue/Processor.php +++ /dev/null @@ -1,36 +0,0 @@ -assertClassExtends(\Exception::class, Exception::class); - } - - public function testShouldImplementExceptionInterface() - { - $this->assertClassImplements(ExceptionInterface::class, Exception::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new Exception(); - } -} diff --git a/pkg/psr-queue/Tests/InvalidDeliveryModeExceptionTest.php b/pkg/psr-queue/Tests/InvalidDeliveryModeExceptionTest.php deleted file mode 100644 index de6e6b817..000000000 --- a/pkg/psr-queue/Tests/InvalidDeliveryModeExceptionTest.php +++ /dev/null @@ -1,37 +0,0 @@ -assertClassExtends(ExceptionInterface::class, InvalidDeliveryModeException::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new InvalidDeliveryModeException(); - } - - public function testThrowIfDeliveryModeIsNotValid() - { - $this->expectException(InvalidDeliveryModeException::class); - $this->expectExceptionMessage('The delivery mode must be one of [2,1].'); - - InvalidDeliveryModeException::assertValidDeliveryMode('is-not-valid'); - } - - public function testShouldDoNothingIfDeliveryModeIsValid() - { - InvalidDeliveryModeException::assertValidDeliveryMode(DeliveryMode::PERSISTENT); - InvalidDeliveryModeException::assertValidDeliveryMode(DeliveryMode::NON_PERSISTENT); - } -} diff --git a/pkg/psr-queue/Tests/InvalidDestinationExceptionTest.php b/pkg/psr-queue/Tests/InvalidDestinationExceptionTest.php deleted file mode 100644 index 48101f6e1..000000000 --- a/pkg/psr-queue/Tests/InvalidDestinationExceptionTest.php +++ /dev/null @@ -1,47 +0,0 @@ -assertClassExtends(ExceptionInterface::class, InvalidDestinationException::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new InvalidDestinationException(); - } - - public function testThrowIfAssertDestinationInstanceOfNotSameAsExpected() - { - $this->expectException(InvalidDestinationException::class); - $this->expectExceptionMessage( - 'The destination must be an instance of Enqueue\Psr\Tests\DestinationBar'. - ' but got Enqueue\Psr\Tests\DestinationFoo.' - ); - - InvalidDestinationException::assertDestinationInstanceOf(new DestinationFoo(), DestinationBar::class); - } - - public function testShouldDoNothingIfAssertDestinationInstanceOfSameAsExpected() - { - InvalidDestinationException::assertDestinationInstanceOf(new DestinationFoo(), DestinationFoo::class); - } -} - -class DestinationBar implements Destination -{ -} - -class DestinationFoo implements Destination -{ -} diff --git a/pkg/psr-queue/Tests/InvalidMessageExceptionTest.php b/pkg/psr-queue/Tests/InvalidMessageExceptionTest.php deleted file mode 100644 index 82f44571b..000000000 --- a/pkg/psr-queue/Tests/InvalidMessageExceptionTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertClassExtends(ExceptionInterface::class, InvalidMessageException::class); - } - - public function testCouldBeConstructedWithoutAnyArguments() - { - new InvalidMessageException(); - } -} diff --git a/pkg/psr-queue/Topic.php b/pkg/psr-queue/Topic.php deleted file mode 100644 index 0f58d28f0..000000000 --- a/pkg/psr-queue/Topic.php +++ /dev/null @@ -1,20 +0,0 @@ -=5.6" - }, - "require-dev": { - "phpunit/phpunit": "~5.5", - "enqueue/test": "^0.2" - }, - "autoload": { - "psr-4": { "Enqueue\\Psr\\": "" }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "minimum-stability": "dev", - "extra": { - "branch-alias": { - "dev-master": "0.2.x-dev" - } - } -} diff --git a/pkg/psr-queue/phpunit.xml.dist b/pkg/psr-queue/phpunit.xml.dist deleted file mode 100644 index 0e744d404..000000000 --- a/pkg/psr-queue/phpunit.xml.dist +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - ./Tests - - - - - - . - - ./vendor - ./Resources - ./Tests - - - - diff --git a/pkg/rdkafka/.gitattributes b/pkg/rdkafka/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/rdkafka/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/rdkafka/.github/workflows/ci.yml b/pkg/rdkafka/.github/workflows/ci.yml new file mode 100644 index 000000000..9e0ceb121 --- /dev/null +++ b/pkg/rdkafka/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: # ext-rdkafka not needed for tests, and a pain to install on CI; + composer-options: "--ignore-platform-req=ext-rdkafka" + + - run: sed -i 's/525568/16777471/' vendor/kwn/php-rdkafka-stubs/stubs/constants.php + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/rdkafka/.gitignore b/pkg/rdkafka/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/rdkafka/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/rdkafka/JsonSerializer.php b/pkg/rdkafka/JsonSerializer.php new file mode 100644 index 000000000..1d25ea55e --- /dev/null +++ b/pkg/rdkafka/JsonSerializer.php @@ -0,0 +1,33 @@ + $message->getBody(), + 'properties' => $message->getProperties(), + 'headers' => $message->getHeaders(), + ]); + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return $json; + } + + public function toMessage(string $string): RdKafkaMessage + { + $data = json_decode($string, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new RdKafkaMessage($data['body'], $data['properties'], $data['headers']); + } +} diff --git a/pkg/rdkafka/LICENSE b/pkg/rdkafka/LICENSE new file mode 100644 index 000000000..f1e6a22fe --- /dev/null +++ b/pkg/rdkafka/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2016 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/rdkafka/README.md b/pkg/rdkafka/README.md new file mode 100644 index 000000000..94f24e510 --- /dev/null +++ b/pkg/rdkafka/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# RdKafka Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/rdkafka/ci.yml?branch=master)](https://github.com/php-enqueue/rdkafka/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/rdkafka/d/total.png)](https://packagist.org/packages/enqueue/rdkafka) +[![Latest Stable Version](https://poser.pugx.org/enqueue/rdkafka/version.png)](https://packagist.org/packages/enqueue/rdkafka) + +This is an implementation of Queue Interop specification. It allows you to send and consume message via Kafka protocol. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/kafka/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/rdkafka/RdKafkaConnectionFactory.php b/pkg/rdkafka/RdKafkaConnectionFactory.php new file mode 100644 index 000000000..24d60c9b6 --- /dev/null +++ b/pkg/rdkafka/RdKafkaConnectionFactory.php @@ -0,0 +1,111 @@ + [ // https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md + * 'metadata.broker.list' => 'localhost:9092', + * ], + * 'topic' => [], + * 'dr_msg_cb' => null, + * 'error_cb' => null, + * 'rebalance_cb' => null, + * 'partitioner' => null, // https://arnaud-lb.github.io/php-rdkafka/phpdoc/rdkafka-topicconf.setpartitioner.html + * 'log_level' => null, + * 'commit_async' => false, + * 'shutdown_timeout' => -1, // https://github.com/arnaud-lb/php-rdkafka#proper-shutdown + * ] + * + * or + * + * kafka://host:port + * + * @param array|string $config + */ + public function __construct($config = 'kafka:') + { + if (version_compare(RdKafkaContext::getLibrdKafkaVersion(), '1.0.0', '<')) { + throw new \RuntimeException('You must install librdkafka:1.0.0 or higher'); + } + + if (empty($config) || 'kafka:' === $config) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace_recursive($this->defaultConfig(), $config); + } + + /** + * @return RdKafkaContext + */ + public function createContext(): Context + { + return new RdKafkaContext($this->config); + } + + private function parseDsn(string $dsn): array + { + $dsnConfig = parse_url($dsn); + if (false === $dsnConfig) { + throw new \LogicException(sprintf('Failed to parse DSN "%s"', $dsn)); + } + + $dsnConfig = array_replace([ + 'scheme' => null, + 'host' => null, + 'port' => null, + 'user' => null, + 'pass' => null, + 'path' => null, + 'query' => null, + ], $dsnConfig); + + if ('kafka' !== $dsnConfig['scheme']) { + throw new \LogicException(sprintf('The given DSN scheme "%s" is not supported. Could be "kafka" only.', $dsnConfig['scheme'])); + } + + $config = []; + if ($dsnConfig['query']) { + parse_str($dsnConfig['query'], $config); + } + + $broker = $dsnConfig['host']; + if ($dsnConfig['port']) { + $broker .= ':'.$dsnConfig['port']; + } + + $config['global']['metadata.broker.list'] = $broker; + + return array_replace_recursive($this->defaultConfig(), $config); + } + + private function defaultConfig(): array + { + return [ + 'global' => [ + 'group.id' => uniqid('', true), + 'metadata.broker.list' => 'localhost:9092', + ], + ]; + } +} diff --git a/pkg/rdkafka/RdKafkaConsumer.php b/pkg/rdkafka/RdKafkaConsumer.php new file mode 100644 index 000000000..8b6cf12c6 --- /dev/null +++ b/pkg/rdkafka/RdKafkaConsumer.php @@ -0,0 +1,192 @@ +consumer = $consumer; + $this->context = $context; + $this->topic = $topic; + $this->subscribed = false; + $this->commitAsync = true; + + $this->setSerializer($serializer); + } + + public function isCommitAsync(): bool + { + return $this->commitAsync; + } + + public function setCommitAsync(bool $async): void + { + $this->commitAsync = $async; + } + + public function getOffset(): ?int + { + return $this->offset; + } + + public function setOffset(?int $offset = null): void + { + if ($this->subscribed) { + throw new \LogicException('The consumer has already subscribed.'); + } + + $this->offset = $offset; + } + + /** + * @return RdKafkaTopic + */ + public function getQueue(): Queue + { + return $this->topic; + } + + /** + * @return RdKafkaMessage + */ + public function receive(int $timeout = 0): ?Message + { + if (false === $this->subscribed) { + if (null === $this->offset) { + $this->consumer->subscribe([$this->getQueue()->getQueueName()]); + } else { + $this->consumer->assign([new TopicPartition( + $this->getQueue()->getQueueName(), + $this->getQueue()->getPartition(), + $this->offset + )]); + } + + $this->subscribed = true; + } + + if ($timeout > 0) { + return $this->doReceive($timeout); + } + + while (true) { + if ($message = $this->doReceive(500)) { + return $message; + } + } + + return null; + } + + /** + * @return RdKafkaMessage + */ + public function receiveNoWait(): ?Message + { + throw new \LogicException('Not implemented'); + } + + /** + * @param RdKafkaMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, RdKafkaMessage::class); + + if (false == $message->getKafkaMessage()) { + throw new \LogicException('The message could not be acknowledged because it does not have kafka message set.'); + } + + if ($this->isCommitAsync()) { + $this->consumer->commitAsync($message->getKafkaMessage()); + } else { + $this->consumer->commit($message->getKafkaMessage()); + } + } + + /** + * @param RdKafkaMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + $this->acknowledge($message); + + if ($requeue) { + $this->context->createProducer()->send($this->topic, $message); + } + } + + private function doReceive(int $timeout): ?RdKafkaMessage + { + $kafkaMessage = $this->consumer->consume($timeout); + + if (null === $kafkaMessage) { + return null; + } + + switch ($kafkaMessage->err) { + case \RD_KAFKA_RESP_ERR__PARTITION_EOF: + case \RD_KAFKA_RESP_ERR__TIMED_OUT: + case \RD_KAFKA_RESP_ERR__TRANSPORT: + return null; + case \RD_KAFKA_RESP_ERR_NO_ERROR: + $message = $this->serializer->toMessage($kafkaMessage->payload); + $message->setKey($kafkaMessage->key); + $message->setPartition($kafkaMessage->partition); + $message->setKafkaMessage($kafkaMessage); + + // Merge headers passed from Kafka with possible earlier serialized payload headers. Prefer Kafka's. + // Note: Requires phprdkafka >= 3.1.0 + if (isset($kafkaMessage->headers)) { + $message->setHeaders(array_merge($message->getHeaders(), $kafkaMessage->headers)); + } + + return $message; + default: + throw new \LogicException($kafkaMessage->errstr(), $kafkaMessage->err); + break; + } + } +} diff --git a/pkg/rdkafka/RdKafkaContext.php b/pkg/rdkafka/RdKafkaContext.php new file mode 100644 index 000000000..a252fcfd5 --- /dev/null +++ b/pkg/rdkafka/RdKafkaContext.php @@ -0,0 +1,223 @@ +config = $config; + $this->kafkaConsumers = []; + $this->rdKafkaConsumers = []; + + $this->setSerializer(new JsonSerializer()); + } + + /** + * @return RdKafkaMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new RdKafkaMessage($body, $properties, $headers); + } + + /** + * @return RdKafkaTopic + */ + public function createTopic(string $topicName): Topic + { + return new RdKafkaTopic($topicName); + } + + /** + * @return RdKafkaTopic + */ + public function createQueue(string $queueName): Queue + { + return new RdKafkaTopic($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return RdKafkaProducer + */ + public function createProducer(): Producer + { + if (!isset($this->producer)) { + $producer = new VendorProducer($this->getConf()); + + if (isset($this->config['log_level'])) { + $producer->setLogLevel($this->config['log_level']); + } + + $this->producer = new RdKafkaProducer($producer, $this->getSerializer()); + + // Once created RdKafkaProducer can store messages internally that need to be delivered before PHP shuts + // down. Otherwise, we are bound to lose messages in transit. + // Note that it is generally preferable to call "close" method explicitly before shutdown starts, since + // otherwise we might not have access to some objects, like database connections. + register_shutdown_function([$this->producer, 'flush'], $this->config['shutdown_timeout'] ?? -1); + } + + return $this->producer; + } + + /** + * @param RdKafkaTopic $destination + * + * @return RdKafkaConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, RdKafkaTopic::class); + + $queueName = $destination->getQueueName(); + + if (!isset($this->rdKafkaConsumers[$queueName])) { + $this->kafkaConsumers[] = $kafkaConsumer = new KafkaConsumer($this->getConf()); + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this, + $destination, + $this->getSerializer() + ); + + if (isset($this->config['commit_async'])) { + $consumer->setCommitAsync($this->config['commit_async']); + } + + $this->rdKafkaConsumers[$queueName] = $consumer; + } + + return $this->rdKafkaConsumers[$queueName]; + } + + public function close(): void + { + $kafkaConsumers = $this->kafkaConsumers; + $this->kafkaConsumers = []; + $this->rdKafkaConsumers = []; + + foreach ($kafkaConsumers as $kafkaConsumer) { + $kafkaConsumer->unsubscribe(); + } + + // Compatibility with phprdkafka 4.0. + if (isset($this->producer)) { + $this->producer->flush($this->config['shutdown_timeout'] ?? -1); + } + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function purgeQueue(Queue $queue): void + { + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public static function getLibrdKafkaVersion(): string + { + if (!defined('RD_KAFKA_VERSION')) { + throw new \RuntimeException('RD_KAFKA_VERSION constant is not defined. Phprdkafka is probably not installed'); + } + $major = (\RD_KAFKA_VERSION & 0xFF000000) >> 24; + $minor = (\RD_KAFKA_VERSION & 0x00FF0000) >> 16; + $patch = (\RD_KAFKA_VERSION & 0x0000FF00) >> 8; + + return "$major.$minor.$patch"; + } + + private function getConf(): Conf + { + if (null === $this->conf) { + $this->conf = new Conf(); + + if (isset($this->config['topic']) && is_array($this->config['topic'])) { + foreach ($this->config['topic'] as $key => $value) { + $this->conf->set($key, $value); + } + } + + if (isset($this->config['partitioner'])) { + $this->conf->set('partitioner', $this->config['partitioner']); + } + + if (isset($this->config['global']) && is_array($this->config['global'])) { + foreach ($this->config['global'] as $key => $value) { + $this->conf->set($key, $value); + } + } + + if (isset($this->config['dr_msg_cb'])) { + $this->conf->setDrMsgCb($this->config['dr_msg_cb']); + } + + if (isset($this->config['error_cb'])) { + $this->conf->setErrorCb($this->config['error_cb']); + } + + if (isset($this->config['rebalance_cb'])) { + $this->conf->setRebalanceCb($this->config['rebalance_cb']); + } + + if (isset($this->config['stats_cb'])) { + $this->conf->setStatsCb($this->config['stats_cb']); + } + } + + return $this->conf; + } +} diff --git a/pkg/rdkafka/RdKafkaMessage.php b/pkg/rdkafka/RdKafkaMessage.php new file mode 100644 index 000000000..7c6d0d005 --- /dev/null +++ b/pkg/rdkafka/RdKafkaMessage.php @@ -0,0 +1,186 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', (string) $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', (string) $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function getPartition(): ?int + { + return $this->partition; + } + + public function setPartition(?int $partition = null): void + { + $this->partition = $partition; + } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(?string $key = null): void + { + $this->key = $key; + } + + public function getKafkaMessage(): ?VendorMessage + { + return $this->kafkaMessage; + } + + public function setKafkaMessage(?VendorMessage $message = null): void + { + $this->kafkaMessage = $message; + } +} diff --git a/pkg/rdkafka/RdKafkaProducer.php b/pkg/rdkafka/RdKafkaProducer.php new file mode 100644 index 000000000..24589b3e7 --- /dev/null +++ b/pkg/rdkafka/RdKafkaProducer.php @@ -0,0 +1,127 @@ +producer = $producer; + + $this->setSerializer($serializer); + } + + /** + * @param RdKafkaTopic $destination + * @param RdKafkaMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, RdKafkaTopic::class); + InvalidMessageException::assertMessageInstanceOf($message, RdKafkaMessage::class); + + $partition = $message->getPartition() ?? $destination->getPartition() ?? \RD_KAFKA_PARTITION_UA; + $payload = $this->serializer->toString($message); + $key = $message->getKey() ?? $destination->getKey() ?? null; + + $topic = $this->producer->newTopic($destination->getTopicName(), $destination->getConf()); + + // Note: Topic::producev method exists in phprdkafka > 3.1.0 + // Headers in payload are maintained for backwards compatibility with apps that might run on lower phprdkafka version + if (method_exists($topic, 'producev')) { + // Phprdkafka <= 3.1.0 will fail calling `producev` on librdkafka >= 1.0.0 causing segfault + // Since we are forcing to use at least librdkafka:1.0.0, no need to check the lib version anymore + if (false !== phpversion('rdkafka') + && version_compare(phpversion('rdkafka'), '3.1.0', '<=')) { + trigger_error( + 'Phprdkafka <= 3.1.0 is incompatible with librdkafka 1.0.0 when calling `producev`. '. + 'Falling back to `produce` (without message headers) instead.', + \E_USER_WARNING + ); + } else { + $topic->producev($partition, 0 /* must be 0 */ , $payload, $key, $message->getHeaders()); + $this->producer->poll(0); + + return; + } + } + + $topic->produce($partition, 0 /* must be 0 */ , $payload, $key); + $this->producer->poll(0); + } + + /** + * @return RdKafkaProducer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + /** + * @return RdKafkaProducer + */ + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getTimeToLive(): ?int + { + return null; + } + + public function flush(int $timeout): ?int + { + // Flush method is exposed in phprdkafka 4.0 + if (method_exists($this->producer, 'flush')) { + return $this->producer->flush($timeout); + } + + return null; + } +} diff --git a/pkg/rdkafka/RdKafkaTopic.php b/pkg/rdkafka/RdKafkaTopic.php new file mode 100644 index 000000000..572f4d024 --- /dev/null +++ b/pkg/rdkafka/RdKafkaTopic.php @@ -0,0 +1,77 @@ +name = $name; + } + + public function getTopicName(): string + { + return $this->name; + } + + public function getQueueName(): string + { + return $this->name; + } + + public function getConf(): ?TopicConf + { + return $this->conf; + } + + public function setConf(?TopicConf $conf = null): void + { + $this->conf = $conf; + } + + public function getPartition(): ?int + { + return $this->partition; + } + + public function setPartition(?int $partition = null): void + { + $this->partition = $partition; + } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(?string $key = null): void + { + $this->key = $key; + } +} diff --git a/pkg/rdkafka/Serializer.php b/pkg/rdkafka/Serializer.php new file mode 100644 index 000000000..7e2a116ed --- /dev/null +++ b/pkg/rdkafka/Serializer.php @@ -0,0 +1,12 @@ +serializer = $serializer; + } + + /** + * @return Serializer + */ + public function getSerializer() + { + return $this->serializer; + } +} diff --git a/pkg/rdkafka/Tests/JsonSerializerTest.php b/pkg/rdkafka/Tests/JsonSerializerTest.php new file mode 100644 index 000000000..6c9bbef84 --- /dev/null +++ b/pkg/rdkafka/Tests/JsonSerializerTest.php @@ -0,0 +1,68 @@ +assertClassImplements(Serializer::class, JsonSerializer::class); + } + + public function testShouldConvertMessageToJsonString() + { + $serializer = new JsonSerializer(); + + $message = new RdKafkaMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $json = $serializer->toString($message); + + $this->assertSame('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}', $json); + } + + public function testThrowIfFailedToEncodeMessageToJson() + { + $serializer = new JsonSerializer(); + + $resource = fopen(__FILE__, 'r'); + + // guard + $this->assertIsResource($resource); + + $message = new RdKafkaMessage('theBody', ['aProp' => $resource]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toString($message); + } + + public function testShouldConvertJsonStringToMessage() + { + $serializer = new JsonSerializer(); + + $message = $serializer->toMessage('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}'); + + $this->assertInstanceOf(RdKafkaMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testThrowIfFailedToDecodeJsonToMessage() + { + $serializer = new JsonSerializer(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toMessage('{]'); + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaConnectionFactoryConfigTest.php b/pkg/rdkafka/Tests/RdKafkaConnectionFactoryConfigTest.php new file mode 100644 index 000000000..7ecb1bd7f --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaConnectionFactoryConfigTest.php @@ -0,0 +1,91 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new RdKafkaConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotSupported() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN scheme "http" is not supported. Could be "kafka" only.'); + + new RdKafkaConnectionFactory('http://example.com'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new RdKafkaConnectionFactory($config); + + $config = $this->readAttribute($factory, 'config'); + + $this->assertNotEmpty($config['global']['group.id']); + + $config['global']['group.id'] = 'group-id'; + $this->assertSame($expectedConfig, $config); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'localhost:9092', + ], + ], + ]; + + yield [ + 'kafka:', + [ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'localhost:9092', + ], + ], + ]; + + yield [ + 'kafka://user:pass@host:10000/db', + [ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'host:10000', + ], + ], + ]; + + yield [ + [], + [ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'localhost:9092', + ], + ], + ]; + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaConnectionFactoryTest.php b/pkg/rdkafka/Tests/RdKafkaConnectionFactoryTest.php new file mode 100644 index 000000000..d7121da65 --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaConnectionFactoryTest.php @@ -0,0 +1,122 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new RdKafkaConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotBeanstalkAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN scheme "http" is not supported. Could be "kafka" only.'); + + new RdKafkaConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Failed to parse DSN "kafka://:@/"'); + + new RdKafkaConnectionFactory('kafka://:@/'); + } + + public function testShouldBeExpectedDefaultConfig() + { + $factory = new RdKafkaConnectionFactory(null); + + $config = $this->readAttribute($factory, 'config'); + + $this->assertNotEmpty($config['global']['group.id']); + + $config['global']['group.id'] = 'group-id'; + $this->assertSame([ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'localhost:9092', + ], + ], $config); + } + + public function testShouldBeExpectedDefaultDsnConfig() + { + $factory = new RdKafkaConnectionFactory('kafka:'); + + $config = $this->readAttribute($factory, 'config'); + + $this->assertNotEmpty($config['global']['group.id']); + + $config['global']['group.id'] = 'group-id'; + $this->assertSame([ + 'global' => [ + 'group.id' => 'group-id', + 'metadata.broker.list' => 'localhost:9092', + ], + ], $config); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new RdKafkaConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + 'kafka://theHost:1234?global%5Bgroup.id%5D=group-id', + [ + 'global' => [ + 'metadata.broker.list' => 'theHost:1234', + 'group.id' => 'group-id', + ], + ], + ]; + + yield [ + [ + 'global' => [ + 'metadata.broker.list' => 'theHost:1234', + 'group.id' => 'group-id', + ], + ], + [ + 'global' => [ + 'metadata.broker.list' => 'theHost:1234', + 'group.id' => 'group-id', + ], + ], + ]; + + yield [ + [ + 'global' => [ + 'group.id' => 'group-id', + ], + ], + [ + 'global' => [ + 'metadata.broker.list' => 'localhost:9092', + 'group.id' => 'group-id', + ], + ], + ]; + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaConsumerTest.php b/pkg/rdkafka/Tests/RdKafkaConsumerTest.php new file mode 100644 index 000000000..e577dfcca --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaConsumerTest.php @@ -0,0 +1,285 @@ +createKafkaConsumerMock(), + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testShouldReceiveFromQueueAndReturnNullIfNoMessageInQueue() + { + $destination = new RdKafkaTopic('dest'); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR__TIMED_OUT; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('subscribe') + ; + $kafkaConsumer + ->expects($this->once()) + ->method('consume') + ->with(1000) + ->willReturn($kafkaMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $this->assertNull($consumer->receive(1000)); + } + + public function testShouldPassProperlyConfiguredTopicPartitionOnAssign() + { + $destination = new RdKafkaTopic('dest'); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR__TIMED_OUT; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('subscribe') + ; + $kafkaConsumer + ->expects($this->any()) + ->method('consume') + ->willReturn($kafkaMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $consumer->receive(1000); + $consumer->receive(1000); + $consumer->receive(1000); + } + + public function testShouldSubscribeOnFirstReceiveOnly() + { + $destination = new RdKafkaTopic('dest'); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR__TIMED_OUT; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('subscribe') + ; + $kafkaConsumer + ->expects($this->any()) + ->method('consume') + ->willReturn($kafkaMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $consumer->receive(1000); + $consumer->receive(1000); + $consumer->receive(1000); + } + + public function testShouldAssignWhenOffsetIsSet() + { + $destination = new RdKafkaTopic('dest'); + $destination->setPartition(1); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR__TIMED_OUT; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('assign') + ; + $kafkaConsumer + ->expects($this->any()) + ->method('consume') + ->willReturn($kafkaMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $consumer->setOffset(123); + + $consumer->receive(1000); + $consumer->receive(1000); + $consumer->receive(1000); + } + + public function testThrowOnOffsetChangeAfterSubscribing() + { + $destination = new RdKafkaTopic('dest'); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR__TIMED_OUT; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('subscribe') + ; + $kafkaConsumer + ->expects($this->any()) + ->method('consume') + ->willReturn($kafkaMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $this->createSerializerMock() + ); + + $consumer->receive(1000); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The consumer has already subscribed.'); + $consumer->setOffset(123); + } + + public function testShouldReceiveFromQueueAndReturnMessageIfMessageInQueue() + { + $destination = new RdKafkaTopic('dest'); + + $expectedMessage = new RdKafkaMessage('theBody', ['foo' => 'fooVal'], ['bar' => 'barVal']); + + $kafkaMessage = new Message(); + $kafkaMessage->err = \RD_KAFKA_RESP_ERR_NO_ERROR; + $kafkaMessage->payload = 'theSerializedMessage'; + $kafkaMessage->partition = 0; + + $kafkaConsumer = $this->createKafkaConsumerMock(); + $kafkaConsumer + ->expects($this->once()) + ->method('subscribe') + ; + $kafkaConsumer + ->expects($this->once()) + ->method('consume') + ->with(1000) + ->willReturn($kafkaMessage) + ; + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toMessage') + ->with('theSerializedMessage') + ->willReturn($expectedMessage) + ; + + $consumer = new RdKafkaConsumer( + $kafkaConsumer, + $this->createContextMock(), + $destination, + $serializer + ); + + $actualMessage = $consumer->receive(1000); + + $this->assertSame($actualMessage, $expectedMessage); + $this->assertSame($kafkaMessage, $actualMessage->getKafkaMessage()); + } + + public function testShouldThrowExceptionNotImplementedOnReceiveNoWait() + { + $consumer = new RdKafkaConsumer( + $this->createKafkaConsumerMock(), + $this->createContextMock(), + new RdKafkaTopic(''), + $this->createSerializerMock() + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Not implemented'); + + $consumer->receiveNoWait(); + } + + public function testShouldAllowGetPreviouslySetSerializer() + { + $consumer = new RdKafkaConsumer( + $this->createKafkaConsumerMock(), + $this->createContextMock(), + new RdKafkaTopic(''), + $this->createSerializerMock() + ); + + $expectedSerializer = $this->createSerializerMock(); + + // guard + $this->assertNotSame($consumer->getSerializer(), $expectedSerializer); + + $consumer->setSerializer($expectedSerializer); + + $this->assertSame($expectedSerializer, $consumer->getSerializer()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|KafkaConsumer + */ + private function createKafkaConsumerMock() + { + return $this->createMock(KafkaConsumer::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|RdKafkaContext + */ + private function createContextMock() + { + return $this->createMock(RdKafkaContext::class); + } + + /** + * @return Serializer|\PHPUnit\Framework\MockObject\MockObject|Serializer + */ + private function createSerializerMock() + { + return $this->createMock(Serializer::class); + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaContextTest.php b/pkg/rdkafka/Tests/RdKafkaContextTest.php new file mode 100644 index 000000000..dc1b597de --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaContextTest.php @@ -0,0 +1,96 @@ +expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + public function testThrowInvalidDestinationIfInvalidDestinationGivenOnCreateConsumer() + { + $context = new RdKafkaContext([]); + + $this->expectException(InvalidDestinationException::class); + $context->createConsumer(new NullQueue('aQueue')); + } + + public function testShouldSetJsonSerializerInConstructor() + { + $context = new RdKafkaContext([]); + + $this->assertInstanceOf(JsonSerializer::class, $context->getSerializer()); + } + + public function testShouldAllowGetPreviouslySetSerializer() + { + $context = new RdKafkaContext([]); + + $expectedSerializer = $this->createMock(Serializer::class); + + $context->setSerializer($expectedSerializer); + + $this->assertSame($expectedSerializer, $context->getSerializer()); + } + + public function testShouldInjectItsSerializerToProducer() + { + $context = new RdKafkaContext([]); + + $producer = $context->createProducer(); + + $this->assertSame($context->getSerializer(), $producer->getSerializer()); + } + + public function testShouldInjectItsSerializerToConsumer() + { + $context = new RdKafkaContext(['global' => [ + 'group.id' => uniqid('', true), + ]]); + + $producer = $context->createConsumer($context->createQueue('aQueue')); + + $this->assertSame($context->getSerializer(), $producer->getSerializer()); + } + + public function testShouldNotCreateConsumerTwice() + { + $context = new RdKafkaContext(['global' => [ + 'group.id' => uniqid('', true), + ]]); + $queue = $context->createQueue('aQueue'); + + $consumer = $context->createConsumer($queue); + $consumer2 = $context->createConsumer($queue); + + $this->assertSame($consumer, $consumer2); + } + + public function testShouldCreateTwoConsumers() + { + $context = new RdKafkaContext(['global' => [ + 'group.id' => uniqid('', true), + ]]); + $queueA = $context->createQueue('aQueue'); + $queueB = $context->createQueue('bQueue'); + + $consumer = $context->createConsumer($queueA); + $consumer2 = $context->createConsumer($queueB); + + $this->assertNotSame($consumer, $consumer2); + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaMessageTest.php b/pkg/rdkafka/Tests/RdKafkaMessageTest.php new file mode 100644 index 000000000..9bcc34642 --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaMessageTest.php @@ -0,0 +1,34 @@ +setPartition(5); + + $this->assertSame(5, $message->getPartition()); + } + + public function testCouldSetGetKey() + { + $message = new RdKafkaMessage(); + $message->setKey('key'); + + $this->assertSame('key', $message->getKey()); + } + + public function testCouldSetGetKafkaMessage() + { + $message = new RdKafkaMessage(); + $message->setKafkaMessage($kafkaMessage = $this->createMock(Message::class)); + + $this->assertSame($kafkaMessage, $message->getKafkaMessage()); + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaProducerTest.php b/pkg/rdkafka/Tests/RdKafkaProducerTest.php new file mode 100644 index 000000000..6295fbc1b --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaProducerTest.php @@ -0,0 +1,401 @@ +createKafkaProducerMock(), $this->createSerializerMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\RdKafka\RdKafkaTopic but got Enqueue\Null\NullQueue.'); + $producer->send(new NullQueue('aQueue'), new RdKafkaMessage()); + } + + public function testThrowIfMessageInvalid() + { + $producer = new RdKafkaProducer($this->createKafkaProducerMock(), $this->createSerializerMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\RdKafka\RdKafkaMessage but it is Enqueue\Null\NullMessage.'); + $producer->send(new RdKafkaTopic('aQueue'), new NullMessage()); + } + + public function testShouldUseSerializerToEncodeMessageAndPutToExpectedTube() + { + $messageHeaders = ['bar' => 'barVal']; + $message = new RdKafkaMessage('theBody', ['foo' => 'fooVal'], $messageHeaders); + $message->setKey('key'); + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + \RD_KAFKA_PARTITION_UA, + 0, + 'theSerializedMessage', + 'key', + $messageHeaders + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->with('theQueueName') + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->with($this->identicalTo($message)) + ->willReturn('theSerializedMessage') + ; + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + + $producer->send(new RdKafkaTopic('theQueueName'), $message); + } + + public function testShouldPassNullAsTopicConfigIfNotSetOnTopic() + { + // guard + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->with('theQueueName', null) + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->willReturn('aSerializedMessage') + ; + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + + $topic = new RdKafkaTopic('theQueueName'); + + // guard + $this->assertNull($topic->getConf()); + + $producer->send($topic, new RdKafkaMessage()); + } + + public function testShouldPassCustomConfAsTopicConfigIfSetOnTopic() + { + $conf = new TopicConf(); + + // guard + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->with('theQueueName', $this->identicalTo($conf)) + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->willReturn('aSerializedMessage') + ; + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + + $topic = new RdKafkaTopic('theQueueName'); + $topic->setConf($conf); + + $producer->send($topic, new RdKafkaMessage()); + } + + public function testShouldAllowGetPreviouslySetSerializer() + { + $producer = new RdKafkaProducer($this->createKafkaProducerMock(), $this->createSerializerMock()); + + $expectedSerializer = $this->createSerializerMock(); + + // guard + $this->assertNotSame($producer->getSerializer(), $expectedSerializer); + + $producer->setSerializer($expectedSerializer); + + $this->assertSame($expectedSerializer, $producer->getSerializer()); + } + + public function testShouldAllowSerializersToSerializeKeys() + { + $messageHeaders = ['bar' => 'barVal']; + $message = new RdKafkaMessage('theBody', ['foo' => 'fooVal'], $messageHeaders); + $message->setKey('key'); + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + \RD_KAFKA_PARTITION_UA, + 0, + 'theSerializedMessage', + 'theSerializedKey' + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->willReturnCallback(function () use ($message) { + $message->setKey('theSerializedKey'); + + return 'theSerializedMessage'; + }) + ; + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + $producer->send(new RdKafkaTopic('theQueueName'), $message); + } + + public function testShouldGetPartitionFromMessage(): void + { + $partition = 1; + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + $partition, + 0, + 'theSerializedMessage', + 'theSerializedKey' + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + $messageHeaders = ['bar' => 'barVal']; + $message = new RdKafkaMessage('theBody', ['foo' => 'fooVal'], $messageHeaders); + $message->setKey('key'); + $message->setPartition($partition); + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->willReturnCallback(function () use ($message) { + $message->setKey('theSerializedKey'); + + return 'theSerializedMessage'; + }) + ; + + $destination = new RdKafkaTopic('theQueueName'); + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + $producer->send($destination, $message); + } + + public function testShouldGetPartitionFromDestination(): void + { + $partition = 2; + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + $partition, + 0, + 'theSerializedMessage', + 'theSerializedKey' + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->willReturn($kafkaTopic) + ; + $kafkaProducer + ->expects($this->once()) + ->method('poll') + ->with(0) + ; + $messageHeaders = ['bar' => 'barVal']; + $message = new RdKafkaMessage('theBody', ['foo' => 'fooVal'], $messageHeaders); + $message->setKey('key'); + + $serializer = $this->createSerializerMock(); + $serializer + ->expects($this->once()) + ->method('toString') + ->willReturnCallback(function () use ($message) { + $message->setKey('theSerializedKey'); + + return 'theSerializedMessage'; + }) + ; + + $destination = new RdKafkaTopic('theQueueName'); + $destination->setPartition($partition); + + $producer = new RdKafkaProducer($kafkaProducer, $serializer); + $producer->send($destination, $message); + } + + public function testShouldAllowFalsyKeyFromMessage(): void + { + $key = 0; + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + \RD_KAFKA_PARTITION_UA, + 0, + '', + $key + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->willReturn($kafkaTopic) + ; + + $message = new RdKafkaMessage(); + $message->setKey($key); + + $producer = new RdKafkaProducer($kafkaProducer, $this->createSerializerMock()); + $producer->send(new RdKafkaTopic(''), $message); + } + + public function testShouldAllowFalsyKeyFromDestination(): void + { + $key = 0; + + $kafkaTopic = $this->createKafkaTopicMock(); + $kafkaTopic + ->expects($this->once()) + ->method('producev') + ->with( + \RD_KAFKA_PARTITION_UA, + 0, + '', + $key + ) + ; + + $kafkaProducer = $this->createKafkaProducerMock(); + $kafkaProducer + ->expects($this->once()) + ->method('newTopic') + ->willReturn($kafkaTopic) + ; + + $destination = new RdKafkaTopic(''); + $destination->setKey($key); + + $producer = new RdKafkaProducer($kafkaProducer, $this->createSerializerMock()); + $producer->send($destination, new RdKafkaMessage()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|ProducerTopic + */ + private function createKafkaTopicMock() + { + return $this->createMock(ProducerTopic::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Producer + */ + private function createKafkaProducerMock() + { + return $this->createMock(Producer::class); + } + + /** + * @return Serializer|\PHPUnit\Framework\MockObject\MockObject|Serializer + */ + private function createSerializerMock() + { + return $this->createMock(Serializer::class); + } +} diff --git a/pkg/rdkafka/Tests/RdKafkaTopicTest.php b/pkg/rdkafka/Tests/RdKafkaTopicTest.php new file mode 100644 index 000000000..d0bc8cc13 --- /dev/null +++ b/pkg/rdkafka/Tests/RdKafkaTopicTest.php @@ -0,0 +1,43 @@ +setPartition(5); + + $this->assertSame(5, $topic->getPartition()); + } + + public function testCouldSetGetKey() + { + $topic = new RdKafkaTopic('topic'); + $topic->setKey('key'); + + $this->assertSame('key', $topic->getKey()); + } + + public function testShouldReturnNullAsConfIfNotSet() + { + $topic = new RdKafkaTopic('topic'); + + $this->assertNull($topic->getConf()); + } + + public function testShouldAllowGetPreviouslySetConf() + { + $topic = new RdKafkaTopic('topic'); + + $conf = new TopicConf(); + $topic->setConf($conf); + + $this->assertSame($conf, $topic->getConf()); + } +} diff --git a/pkg/rdkafka/Tests/Spec/RdKafkaConnectionFactoryTest.php b/pkg/rdkafka/Tests/Spec/RdKafkaConnectionFactoryTest.php new file mode 100644 index 000000000..a582aadca --- /dev/null +++ b/pkg/rdkafka/Tests/Spec/RdKafkaConnectionFactoryTest.php @@ -0,0 +1,14 @@ + [ + 'group.id' => 'group', + ], + ]); + } +} diff --git a/pkg/rdkafka/Tests/Spec/RdKafkaMessageTest.php b/pkg/rdkafka/Tests/Spec/RdKafkaMessageTest.php new file mode 100644 index 000000000..9e230d1b6 --- /dev/null +++ b/pkg/rdkafka/Tests/Spec/RdKafkaMessageTest.php @@ -0,0 +1,14 @@ +createContext(); + + $topic = $this->createTopic($context, uniqid('', true)); + + $expectedBody = __CLASS__.time(); + $producer = $context->createProducer(); + $producer->send($topic, $context->createMessage($expectedBody)); + + // Calling close causes Producer to flush (wait for messages to be delivered to Kafka) + $context->close(); + + $consumer = $context->createConsumer($topic); + + $context->createProducer()->send($topic, $context->createMessage($expectedBody)); + + // Initial balancing can take some time, so we want to make sure the timeout is high enough + $message = $consumer->receive(15000); // 15 sec + + $this->assertInstanceOf(Message::class, $message); + $consumer->acknowledge($message); + + $this->assertSame($expectedBody, $message->getBody()); + } + + protected function createContext() + { + $config = [ + 'global' => [ + 'group.id' => uniqid('', true), + 'metadata.broker.list' => getenv('RDKAFKA_HOST').':'.getenv('RDKAFKA_PORT'), + 'enable.auto.commit' => 'false', + ], + 'topic' => [ + 'auto.offset.reset' => 'earliest', + ], + ]; + + $context = (new RdKafkaConnectionFactory($config))->createContext(); + + return $context; + } +} diff --git a/pkg/rdkafka/Tests/Spec/RdKafkaTopicTest.php b/pkg/rdkafka/Tests/Spec/RdKafkaTopicTest.php new file mode 100644 index 000000000..08d427883 --- /dev/null +++ b/pkg/rdkafka/Tests/Spec/RdKafkaTopicTest.php @@ -0,0 +1,14 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/redis/.gitattributes b/pkg/redis/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/redis/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/redis/.github/workflows/ci.yml b/pkg/redis/.github/workflows/ci.yml new file mode 100644 index 000000000..57d501bee --- /dev/null +++ b/pkg/redis/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: redis + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/redis/.gitignore b/pkg/redis/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/redis/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/redis/JsonSerializer.php b/pkg/redis/JsonSerializer.php new file mode 100644 index 000000000..ff67ed880 --- /dev/null +++ b/pkg/redis/JsonSerializer.php @@ -0,0 +1,33 @@ + $message->getBody(), + 'properties' => $message->getProperties(), + 'headers' => $message->getHeaders(), + ]); + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return $json; + } + + public function toMessage(string $string): RedisMessage + { + $data = json_decode($string, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new RedisMessage($data['body'], $data['properties'], $data['headers']); + } +} diff --git a/pkg/redis/LICENSE b/pkg/redis/LICENSE new file mode 100644 index 000000000..27881b7c4 --- /dev/null +++ b/pkg/redis/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2017 Forma-Pro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/redis/LuaScripts.php b/pkg/redis/LuaScripts.php new file mode 100644 index 000000000..4a40d2447 --- /dev/null +++ b/pkg/redis/LuaScripts.php @@ -0,0 +1,38 @@ +options = $config['predis_options']; + + $this->parameters = [ + 'scheme' => $config['scheme'], + 'host' => $config['host'], + 'port' => $config['port'], + 'password' => $config['password'], + 'database' => $config['database'], + 'path' => $config['path'], + 'async' => $config['async'], + 'persistent' => $config['persistent'], + 'timeout' => $config['timeout'], + 'read_write_timeout' => $config['read_write_timeout'], + ]; + + if ($config['ssl']) { + $this->parameters['ssl'] = $config['ssl']; + } + } + + public function eval(string $script, array $keys = [], array $args = []) + { + try { + // mixed eval($script, $numkeys, $keyOrArg1 = null, $keyOrArgN = null) + return call_user_func_array([$this->redis, 'eval'], array_merge([$script, count($keys)], $keys, $args)); + } catch (PRedisServerException $e) { + throw new ServerException('eval command has failed', 0, $e); + } + } + + public function zadd(string $key, string $value, float $score): int + { + try { + return $this->redis->zadd($key, [$value => $score]); + } catch (PRedisServerException $e) { + throw new ServerException('zadd command has failed', 0, $e); + } + } + + public function zrem(string $key, string $value): int + { + try { + return $this->redis->zrem($key, [$value]); + } catch (PRedisServerException $e) { + throw new ServerException('zrem command has failed', 0, $e); + } + } + + public function lpush(string $key, string $value): int + { + try { + return $this->redis->lpush($key, [$value]); + } catch (PRedisServerException $e) { + throw new ServerException('lpush command has failed', 0, $e); + } + } + + public function brpop(array $keys, int $timeout): ?RedisResult + { + try { + if ($result = $this->redis->brpop($keys, $timeout)) { + return new RedisResult($result[0], $result[1]); + } + + return null; + } catch (PRedisServerException $e) { + throw new ServerException('brpop command has failed', 0, $e); + } + } + + public function rpop(string $key): ?RedisResult + { + try { + if ($message = $this->redis->rpop($key)) { + return new RedisResult($key, $message); + } + + return null; + } catch (PRedisServerException $e) { + throw new ServerException('rpop command has failed', 0, $e); + } + } + + public function connect(): void + { + if ($this->redis) { + return; + } + + $this->redis = new Client($this->parameters, $this->options); + + // No need to pass "auth" here because Predis already handles this internally + + $this->redis->connect(); + } + + public function disconnect(): void + { + $this->redis->disconnect(); + } + + public function del(string $key): void + { + $this->redis->del([$key]); + } +} diff --git a/pkg/redis/PhpRedis.php b/pkg/redis/PhpRedis.php new file mode 100644 index 000000000..9e820a283 --- /dev/null +++ b/pkg/redis/PhpRedis.php @@ -0,0 +1,142 @@ +config = $config; + } + + public function eval(string $script, array $keys = [], array $args = []) + { + try { + return $this->redis->eval($script, array_merge($keys, $args), count($keys)); + } catch (\RedisException $e) { + throw new ServerException('eval command has failed', 0, $e); + } + } + + public function zadd(string $key, string $value, float $score): int + { + try { + return $this->redis->zAdd($key, $score, $value); + } catch (\RedisException $e) { + throw new ServerException('zadd command has failed', 0, $e); + } + } + + public function zrem(string $key, string $value): int + { + try { + return $this->redis->zRem($key, $value); + } catch (\RedisException $e) { + throw new ServerException('zrem command has failed', 0, $e); + } + } + + public function lpush(string $key, string $value): int + { + try { + return $this->redis->lPush($key, $value); + } catch (\RedisException $e) { + throw new ServerException('lpush command has failed', 0, $e); + } + } + + public function brpop(array $keys, int $timeout): ?RedisResult + { + try { + if ($result = $this->redis->brPop($keys, $timeout)) { + return new RedisResult($result[0], $result[1]); + } + + return null; + } catch (\RedisException $e) { + throw new ServerException('brpop command has failed', 0, $e); + } + } + + public function rpop(string $key): ?RedisResult + { + try { + if ($message = $this->redis->rPop($key)) { + return new RedisResult($key, $message); + } + + return null; + } catch (\RedisException $e) { + throw new ServerException('rpop command has failed', 0, $e); + } + } + + public function connect(): void + { + if ($this->redis) { + return; + } + + $supportedSchemes = ['redis', 'rediss', 'tcp', 'unix']; + if (false == in_array($this->config['scheme'], $supportedSchemes, true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported by php extension. It must be one of "%s"', $this->config['scheme'], implode('", "', $supportedSchemes))); + } + + $this->redis = new \Redis(); + + $connectionMethod = $this->config['persistent'] ? 'pconnect' : 'connect'; + + $host = 'rediss' === $this->config['scheme'] ? 'tls://'.$this->config['host'] : $this->config['host']; + + $result = call_user_func( + [$this->redis, $connectionMethod], + 'unix' === $this->config['scheme'] ? $this->config['path'] : $host, + $this->config['port'], + $this->config['timeout'], + $this->config['persistent'] ? ($this->config['phpredis_persistent_id'] ?? null) : null, + (int) ($this->config['phpredis_retry_interval'] ?? 0), + (float) $this->config['read_write_timeout'] ?? 0 + ); + + if (false == $result) { + throw new ServerException('Failed to connect.'); + } + + if ($this->config['password']) { + $this->redis->auth($this->config['password']); + } + + if (null !== $this->config['database']) { + $this->redis->select($this->config['database']); + } + } + + public function disconnect(): void + { + if ($this->redis) { + $this->redis->close(); + } + } + + public function del(string $key): void + { + $this->redis->del($key); + } +} diff --git a/pkg/redis/README.md b/pkg/redis/README.md new file mode 100644 index 000000000..7b368bb35 --- /dev/null +++ b/pkg/redis/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Redis Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/redis/ci.yml?branch=master)](https://github.com/php-enqueue/redis/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/redis/d/total.png)](https://packagist.org/packages/enqueue/redis) +[![Latest Stable Version](https://poser.pugx.org/enqueue/redis/version.png)](https://packagist.org/packages/enqueue/redis) + +This is an implementation of Queue Interop specification. It allows you to send and consume message with Redis store as a broker. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/redis/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/redis/Redis.php b/pkg/redis/Redis.php new file mode 100644 index 000000000..04165af9d --- /dev/null +++ b/pkg/redis/Redis.php @@ -0,0 +1,55 @@ + A redis DSN string. + * 'scheme' => Specifies the protocol used to communicate with an instance of Redis. + * 'host' => IP or hostname of the target server. + * 'port' => TCP/IP port of the target server. + * 'path' => Path of the UNIX domain socket file used when connecting to Redis using UNIX domain sockets. + * 'database' => Accepts a numeric value that is used by Predis to automatically select a logical database with the SELECT command. + * 'password' => Accepts a value used to authenticate with a Redis server protected by password with the AUTH command. + * 'async' => Specifies if connections to the server is estabilished in a non-blocking way (that is, the client is not blocked while the underlying resource performs the actual connection). + * 'persistent' => Specifies if the underlying connection resource should be left open when a script ends its lifecycle. + * 'lazy' => The connection will be performed as later as possible, if the option set to true + * 'timeout' => Timeout (expressed in seconds) used to connect to a Redis server after which an exception is thrown. + * 'read_write_timeout' => Timeout (expressed in seconds) used when performing read or write operations on the underlying network resource after which an exception is thrown. + * 'predis_options' => An array of predis specific options. + * 'ssl' => could be any of http://fi2.php.net/manual/en/context.ssl.php#refsect1-context.ssl-options + * 'redelivery_delay' => Default 300 sec. Returns back message into the queue if message was not acknowledged or rejected after this delay. + * It could happen if consumer has failed with fatal error or even if message processing is slow and takes more than this time. + * ]. + * + * or + * + * redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111 + * tls://127.0.0.1?ssl[cafile]=private.pem&ssl[verify_peer]=1 + * + * or + * + * instance of Enqueue\Redis + * + * @param array|string|Redis|null $config + */ + public function __construct($config = 'redis:') + { + if ($config instanceof Redis) { + $this->redis = $config; + $this->config = $this->defaultConfig(); + + return; + } + + if (empty($config)) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace_recursive($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', Redis::class)); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return RedisContext + */ + public function createContext(): Context + { + if ($this->config['lazy']) { + return new RedisContext(function () { + return $this->createRedis(); + }, (int) $this->config['redelivery_delay']); + } + + return new RedisContext($this->createRedis(), (int) $this->config['redelivery_delay']); + } + + private function createRedis(): Redis + { + if (false == $this->redis) { + if (in_array('phpredis', $this->config['scheme_extensions'], true)) { + $this->redis = new PhpRedis($this->config); + } else { + $this->redis = new PRedis($this->config); + } + + $this->redis->connect(); + } + + return $this->redis; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + $supportedSchemes = ['redis', 'rediss', 'tcp', 'tls', 'unix']; + if (false == in_array($dsn->getSchemeProtocol(), $supportedSchemes, true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be one of "%s"', $dsn->getSchemeProtocol(), implode('", "', $supportedSchemes))); + } + + $database = $dsn->getDecimal('database'); + + // try use path as database name if not set. + if (null === $database && 'unix' !== $dsn->getSchemeProtocol() && null !== $dsn->getPath()) { + $database = (int) ltrim($dsn->getPath(), '/'); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'scheme' => $dsn->getSchemeProtocol(), + 'scheme_extensions' => $dsn->getSchemeExtensions(), + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'path' => $dsn->getPath(), + 'database' => $database, + 'password' => $dsn->getPassword() ?: $dsn->getUser() ?: $dsn->getString('password'), + 'async' => $dsn->getBool('async'), + 'persistent' => $dsn->getBool('persistent'), + 'timeout' => $dsn->getFloat('timeout'), + 'read_write_timeout' => $dsn->getFloat('read_write_timeout'), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'scheme' => 'redis', + 'scheme_extensions' => [], + 'host' => '127.0.0.1', + 'port' => 6379, + 'path' => null, + 'database' => null, + 'password' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'timeout' => 5.0, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ]; + } +} diff --git a/pkg/redis/RedisConsumer.php b/pkg/redis/RedisConsumer.php new file mode 100644 index 000000000..ca3733d4b --- /dev/null +++ b/pkg/redis/RedisConsumer.php @@ -0,0 +1,121 @@ +context = $context; + $this->queue = $queue; + } + + public function getRedeliveryDelay(): ?int + { + return $this->redeliveryDelay; + } + + public function setRedeliveryDelay(int $delay): void + { + $this->redeliveryDelay = $delay; + } + + /** + * @return RedisDestination + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return RedisMessage + */ + public function receive(int $timeout = 0): ?Message + { + $timeout = (int) ceil($timeout / 1000); + + if ($timeout <= 0) { + while (true) { + if ($message = $this->receive(5000)) { + return $message; + } + } + } + + return $this->receiveMessage([$this->queue], $timeout, $this->redeliveryDelay); + } + + /** + * @return RedisMessage + */ + public function receiveNoWait(): ?Message + { + return $this->receiveMessageNoWait($this->queue, $this->redeliveryDelay); + } + + /** + * @param RedisMessage $message + */ + public function acknowledge(Message $message): void + { + $this->getRedis()->zrem($this->queue->getName().':reserved', $message->getReservedKey()); + } + + /** + * @param RedisMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, RedisMessage::class); + + $this->acknowledge($message); + + if ($requeue) { + $message = $this->getContext()->getSerializer()->toMessage($message->getReservedKey()); + $message->setRedelivered(true); + + if ($message->getTimeToLive()) { + $message->setHeader('expires_at', time() + $message->getTimeToLive()); + } + + $payload = $this->getContext()->getSerializer()->toString($message); + + $this->getRedis()->lpush($this->queue->getName(), $payload); + } + } + + private function getContext(): RedisContext + { + return $this->context; + } + + private function getRedis(): Redis + { + return $this->context->getRedis(); + } +} diff --git a/pkg/redis/RedisConsumerHelperTrait.php b/pkg/redis/RedisConsumerHelperTrait.php new file mode 100644 index 000000000..063ff1fbd --- /dev/null +++ b/pkg/redis/RedisConsumerHelperTrait.php @@ -0,0 +1,114 @@ +queueNames) { + $this->queueNames = []; + foreach ($queues as $queue) { + $this->queueNames[] = $queue->getName(); + } + } + + while ($thisTimeout > 0) { + $this->migrateExpiredMessages($this->queueNames); + + if (false == $result = $this->getContext()->getRedis()->brpop($this->queueNames, $thisTimeout)) { + return null; + } + + $this->pushQueueNameBack($result->getKey()); + + if ($message = $this->processResult($result, $redeliveryDelay)) { + return $message; + } + + $thisTimeout -= time() - $startAt; + } + + return null; + } + + protected function receiveMessageNoWait(RedisDestination $destination, int $redeliveryDelay): ?RedisMessage + { + $this->migrateExpiredMessages([$destination->getName()]); + + if ($result = $this->getContext()->getRedis()->rpop($destination->getName())) { + return $this->processResult($result, $redeliveryDelay); + } + + return null; + } + + protected function processResult(RedisResult $result, int $redeliveryDelay): ?RedisMessage + { + $message = $this->getContext()->getSerializer()->toMessage($result->getMessage()); + + $now = time(); + + if (0 === $message->getAttempts() && $expiresAt = $message->getHeader('expires_at')) { + if ($now > $expiresAt) { + return null; + } + } + + $message->setHeader('attempts', $message->getAttempts() + 1); + $message->setRedelivered($message->getAttempts() > 1); + $message->setKey($result->getKey()); + $message->setReservedKey($this->getContext()->getSerializer()->toString($message)); + + $reservedQueue = $result->getKey().':reserved'; + $redeliveryAt = $now + $redeliveryDelay; + + $this->getContext()->getRedis()->zadd($reservedQueue, $message->getReservedKey(), $redeliveryAt); + + return $message; + } + + protected function pushQueueNameBack(string $queueName): void + { + if (count($this->queueNames) <= 1) { + return; + } + + if (false === $from = array_search($queueName, $this->queueNames, true)) { + throw new \LogicException(sprintf('Queue name was not found: "%s"', $queueName)); + } + + $to = count($this->queueNames) - 1; + + $out = array_splice($this->queueNames, $from, 1); + array_splice($this->queueNames, $to, 0, $out); + } + + protected function migrateExpiredMessages(array $queueNames): void + { + $now = time(); + + foreach ($queueNames as $queueName) { + $this->getContext()->getRedis() + ->eval(LuaScripts::migrateExpired(), [$queueName.':delayed', $queueName], [$now]); + + $this->getContext()->getRedis() + ->eval(LuaScripts::migrateExpired(), [$queueName.':reserved', $queueName], [$now]); + } + } +} diff --git a/pkg/redis/RedisContext.php b/pkg/redis/RedisContext.php new file mode 100644 index 000000000..346375f8d --- /dev/null +++ b/pkg/redis/RedisContext.php @@ -0,0 +1,172 @@ +redis = $redis; + } elseif (is_callable($redis)) { + $this->redisFactory = $redis; + } else { + throw new \InvalidArgumentException(sprintf('The $redis argument must be either %s or callable that returns %s once called.', Redis::class, Redis::class)); + } + + $this->redeliveryDelay = $redeliveryDelay; + $this->setSerializer(new JsonSerializer()); + } + + /** + * @return RedisMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new RedisMessage($body, $properties, $headers); + } + + /** + * @return RedisDestination + */ + public function createTopic(string $topicName): Topic + { + return new RedisDestination($topicName); + } + + /** + * @return RedisDestination + */ + public function createQueue(string $queueName): Queue + { + return new RedisDestination($queueName); + } + + /** + * @param RedisDestination $queue + */ + public function deleteQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, RedisDestination::class); + + $this->deleteDestination($queue); + } + + /** + * @param RedisDestination $topic + */ + public function deleteTopic(Topic $topic): void + { + InvalidDestinationException::assertDestinationInstanceOf($topic, RedisDestination::class); + + $this->deleteDestination($topic); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return RedisProducer + */ + public function createProducer(): Producer + { + return new RedisProducer($this); + } + + /** + * @param RedisDestination $destination + * + * @return RedisConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, RedisDestination::class); + + $consumer = new RedisConsumer($this, $destination); + $consumer->setRedeliveryDelay($this->redeliveryDelay); + + return $consumer; + } + + /** + * @return RedisSubscriptionConsumer + */ + public function createSubscriptionConsumer(): SubscriptionConsumer + { + $consumer = new RedisSubscriptionConsumer($this); + $consumer->setRedeliveryDelay($this->redeliveryDelay); + + return $consumer; + } + + /** + * @param RedisDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + $this->deleteDestination($queue); + } + + public function close(): void + { + $this->getRedis()->disconnect(); + } + + public function getRedis(): Redis + { + if (false == $this->redis) { + $redis = call_user_func($this->redisFactory); + if (false == $redis instanceof Redis) { + throw new \LogicException(sprintf('The factory must return instance of %s. It returned %s', Redis::class, is_object($redis) ? $redis::class : gettype($redis))); + } + + $this->redis = $redis; + } + + return $this->redis; + } + + private function deleteDestination(RedisDestination $destination): void + { + $this->getRedis()->del($destination->getName()); + $this->getRedis()->del($destination->getName().':delayed'); + $this->getRedis()->del($destination->getName().':reserved'); + } +} diff --git a/pkg/redis/RedisDestination.php b/pkg/redis/RedisDestination.php new file mode 100644 index 000000000..72e61e5a1 --- /dev/null +++ b/pkg/redis/RedisDestination.php @@ -0,0 +1,36 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function getQueueName(): string + { + return $this->getName(); + } + + public function getTopicName(): string + { + return $this->getName(); + } +} diff --git a/pkg/redis/RedisMessage.php b/pkg/redis/RedisMessage.php new file mode 100644 index 000000000..708bdbc97 --- /dev/null +++ b/pkg/redis/RedisMessage.php @@ -0,0 +1,202 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + + $this->redelivered = false; + } + + public function getBody(): string + { + return $this->body; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = (bool) $redelivered; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function getAttempts(): int + { + return (int) $this->getHeader('attempts', 0); + } + + public function getTimeToLive(): ?int + { + return $this->getHeader('time_to_live'); + } + + /** + * Set time to live in milliseconds. + */ + public function setTimeToLive(?int $timeToLive = null): void + { + $this->setHeader('time_to_live', $timeToLive); + } + + public function getDeliveryDelay(): ?int + { + return $this->getHeader('delivery_delay'); + } + + /** + * Set delay in milliseconds. + */ + public function setDeliveryDelay(?int $deliveryDelay = null): void + { + $this->setHeader('delivery_delay', $deliveryDelay); + } + + public function getReservedKey(): ?string + { + return $this->reservedKey; + } + + public function setReservedKey(string $reservedKey) + { + $this->reservedKey = $reservedKey; + } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(string $key): void + { + $this->key = $key; + } +} diff --git a/pkg/redis/RedisProducer.php b/pkg/redis/RedisProducer.php new file mode 100644 index 000000000..3ad3e5bb2 --- /dev/null +++ b/pkg/redis/RedisProducer.php @@ -0,0 +1,117 @@ +context = $context; + } + + /** + * @param RedisDestination $destination + * @param RedisMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, RedisDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, RedisMessage::class); + + $message->setMessageId(Uuid::uuid4()->toString()); + $message->setHeader('attempts', 0); + + if (null !== $this->timeToLive && null === $message->getTimeToLive()) { + $message->setTimeToLive($this->timeToLive); + } + + if (null !== $this->deliveryDelay && null === $message->getDeliveryDelay()) { + $message->setDeliveryDelay($this->deliveryDelay); + } + + if ($message->getTimeToLive()) { + $message->setHeader('expires_at', time() + $message->getTimeToLive()); + } + + $payload = $this->context->getSerializer()->toString($message); + + if ($message->getDeliveryDelay()) { + $deliveryAt = time() + $message->getDeliveryDelay() / 1000; + $this->context->getRedis()->zadd($destination->getName().':delayed', $payload, $deliveryAt); + } else { + $this->context->getRedis()->lpush($destination->getName(), $payload); + } + } + + /** + * @return self + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return RedisProducer + */ + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @return self + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/pkg/redis/RedisResult.php b/pkg/redis/RedisResult.php new file mode 100644 index 000000000..83a90f576 --- /dev/null +++ b/pkg/redis/RedisResult.php @@ -0,0 +1,34 @@ +key = $key; + $this->message = $message; + } + + public function getKey(): string + { + return $this->key; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/pkg/redis/RedisSubscriptionConsumer.php b/pkg/redis/RedisSubscriptionConsumer.php new file mode 100644 index 000000000..d0b34634d --- /dev/null +++ b/pkg/redis/RedisSubscriptionConsumer.php @@ -0,0 +1,132 @@ +context = $context; + $this->subscribers = []; + } + + public function getRedeliveryDelay(): ?int + { + return $this->redeliveryDelay; + } + + public function setRedeliveryDelay(int $delay): void + { + $this->redeliveryDelay = $delay; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('No subscribers'); + } + + $timeout = (int) ceil($timeout / 1000); + $endAt = time() + $timeout; + + $queues = []; + /** @var Consumer $consumer */ + foreach ($this->subscribers as list($consumer)) { + $queues[] = $consumer->getQueue(); + } + + while (true) { + if ($message = $this->receiveMessage($queues, $timeout ?: 5, $this->redeliveryDelay)) { + list($consumer, $callback) = $this->subscribers[$message->getKey()]; + + if (false === call_user_func($callback, $message, $consumer)) { + return; + } + } + + if ($timeout && microtime(true) >= $endAt) { + return; + } + } + } + + /** + * @param RedisConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof RedisConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', RedisConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + if (array_key_exists($queueName, $this->subscribers)) { + if ($this->subscribers[$queueName][0] === $consumer && $this->subscribers[$queueName][1] === $callback) { + return; + } + + throw new \InvalidArgumentException(sprintf('There is a consumer subscribed to queue: "%s"', $queueName)); + } + + $this->subscribers[$queueName] = [$consumer, $callback]; + $this->queueNames = null; + } + + /** + * @param RedisConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof RedisConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', RedisConsumer::class, $consumer::class)); + } + + $queueName = $consumer->getQueue()->getQueueName(); + + if (false == array_key_exists($queueName, $this->subscribers)) { + return; + } + + if ($this->subscribers[$queueName][0] !== $consumer) { + return; + } + + unset($this->subscribers[$queueName]); + $this->queueNames = null; + } + + public function unsubscribeAll(): void + { + $this->subscribers = []; + $this->queueNames = null; + } + + private function getContext(): RedisContext + { + return $this->context; + } +} diff --git a/pkg/redis/Serializer.php b/pkg/redis/Serializer.php new file mode 100644 index 000000000..a936a9328 --- /dev/null +++ b/pkg/redis/Serializer.php @@ -0,0 +1,12 @@ +serializer = $serializer; + } + + /** + * @return Serializer + */ + public function getSerializer() + { + return $this->serializer; + } +} diff --git a/pkg/redis/ServerException.php b/pkg/redis/ServerException.php new file mode 100644 index 000000000..f7503f57b --- /dev/null +++ b/pkg/redis/ServerException.php @@ -0,0 +1,11 @@ +getContext()->createQueue('enqueue.test_queue'); + + $startAt = microtime(true); + + $consumer = $this->getContext()->createConsumer($queue); + $message = $consumer->receive(2000); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertGreaterThan(1.5, $endAt - $startAt); + $this->assertLessThan(2.5, $endAt - $startAt); + } + + public function testReturnNullImmediatelyOnReceiveNoWait() + { + $queue = $this->getContext()->createQueue('enqueue.test_queue'); + + $startAt = microtime(true); + + $consumer = $this->getContext()->createConsumer($queue); + $message = $consumer->receiveNoWait(); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertLessThan(0.5, $endAt - $startAt); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToQueue() + { + $queue = $this->getContext()->createQueue('enqueue.test_queue'); + + $message = $this->getContext()->createMessage( + __METHOD__, + ['FooProperty' => 'FooVal'], + ['BarHeader' => 'BarVal'] + ); + + $producer = $this->getContext()->createProducer(); + $producer->send($queue, $message); + + $consumer = $this->getContext()->createConsumer($queue); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(RedisMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + $this->assertEquals(['FooProperty' => 'FooVal'], $message->getProperties()); + $this->assertCount(3, $message->getHeaders()); + $this->assertSame(1, $message->getHeader('attempts')); + $this->assertSame('BarVal', $message->getHeader('BarHeader')); + $this->assertNotEmpty('BarVal', $message->getHeader('message_id')); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToTopic() + { + $topic = $this->getContext()->createTopic('enqueue.test_topic'); + + $message = $this->getContext()->createMessage(__METHOD__); + + $producer = $this->getContext()->createProducer(); + $producer->send($topic, $message); + + $consumer = $this->getContext()->createConsumer($topic); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(RedisMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + } + + public function testConsumerReceiveMessageWithZeroTimeout() + { + $topic = $this->getContext()->createTopic('enqueue.test_topic'); + + $consumer = $this->getContext()->createConsumer($topic); + + // guard + $this->assertNull($consumer->receive(1000)); + + $message = $this->getContext()->createMessage(__METHOD__); + + $producer = $this->getContext()->createProducer(); + $producer->send($topic, $message); + usleep(100); + $actualMessage = $consumer->receive(0); + + $this->assertInstanceOf(RedisMessage::class, $actualMessage); + $consumer->acknowledge($actualMessage); + + $this->assertEquals(__METHOD__, $message->getBody()); + } + + public function testShouldReceiveMessagesInExpectedOrder() + { + $queue = $this->getContext()->createQueue('enqueue.test_queue'); + + $producer = $this->getContext()->createProducer(); + $producer->send($queue, $this->getContext()->createMessage('1')); + $producer->send($queue, $this->getContext()->createMessage('2')); + $producer->send($queue, $this->getContext()->createMessage('3')); + + $consumer = $this->getContext()->createConsumer($queue); + + $this->assertSame('1', $consumer->receiveNoWait()->getBody()); + $this->assertSame('2', $consumer->receiveNoWait()->getBody()); + $this->assertSame('3', $consumer->receiveNoWait()->getBody()); + } + + /** + * @return RedisContext + */ + abstract protected function getContext(); +} diff --git a/pkg/redis/Tests/Functional/ConsumptionUseCasesTrait.php b/pkg/redis/Tests/Functional/ConsumptionUseCasesTrait.php new file mode 100644 index 000000000..0f69070f6 --- /dev/null +++ b/pkg/redis/Tests/Functional/ConsumptionUseCasesTrait.php @@ -0,0 +1,75 @@ +getContext()->createQueue('enqueue.test_queue'); + + $message = $this->getContext()->createMessage(__METHOD__); + $this->getContext()->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->getContext(), new ChainExtension([ + new LimitConsumedMessagesExtension(1), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + ])); + + $processor = new StubProcessor(); + $queueConsumer->bind($queue, $processor); + + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + } + + public function testConsumeOneMessageAndSendReplyExit() + { + $queue = $this->getContext()->createQueue('enqueue.test_queue'); + + $replyQueue = $this->getContext()->createQueue('enqueue.test_queue_reply'); + + $message = $this->getContext()->createMessage(__METHOD__); + $message->setReplyTo($replyQueue->getQueueName()); + $this->getContext()->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->getContext(), new ChainExtension([ + new LimitConsumedMessagesExtension(2), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + new ReplyExtension(), + ])); + + $replyMessage = $this->getContext()->createMessage(__METHOD__.'.reply'); + + $processor = new StubProcessor(); + $processor->result = Result::reply($replyMessage); + + $replyProcessor = new StubProcessor(); + + $queueConsumer->bind($queue, $processor); + $queueConsumer->bind($replyQueue, $replyProcessor); + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + + $this->assertInstanceOf(Message::class, $replyProcessor->lastProcessedMessage); + $this->assertEquals(__METHOD__.'.reply', $replyProcessor->lastProcessedMessage->getBody()); + } + + /** + * @return RedisContext + */ + abstract protected function getContext(); +} diff --git a/pkg/redis/Tests/Functional/PRedisCommonUseCasesTest.php b/pkg/redis/Tests/Functional/PRedisCommonUseCasesTest.php new file mode 100644 index 000000000..9ac2a037b --- /dev/null +++ b/pkg/redis/Tests/Functional/PRedisCommonUseCasesTest.php @@ -0,0 +1,39 @@ +context = $this->buildPRedisContext(); + + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue')); + $this->context->deleteTopic($this->context->createTopic('enqueue.test_topic')); + } + + protected function tearDown(): void + { + $this->context->close(); + } + + protected function getContext() + { + return $this->context; + } +} diff --git a/pkg/redis/Tests/Functional/PRedisConsumptionUseCasesTest.php b/pkg/redis/Tests/Functional/PRedisConsumptionUseCasesTest.php new file mode 100644 index 000000000..e61cd1f0f --- /dev/null +++ b/pkg/redis/Tests/Functional/PRedisConsumptionUseCasesTest.php @@ -0,0 +1,39 @@ +context = $this->buildPRedisContext(); + + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue')); + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue_reply')); + } + + protected function tearDown(): void + { + $this->context->close(); + } + + protected function getContext() + { + return $this->context; + } +} diff --git a/pkg/redis/Tests/Functional/PhpRedisCommonUseCasesTest.php b/pkg/redis/Tests/Functional/PhpRedisCommonUseCasesTest.php new file mode 100644 index 000000000..f36843ec9 --- /dev/null +++ b/pkg/redis/Tests/Functional/PhpRedisCommonUseCasesTest.php @@ -0,0 +1,39 @@ +context = $this->buildPhpRedisContext(); + + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue')); + $this->context->deleteTopic($this->context->createTopic('enqueue.test_topic')); + } + + protected function tearDown(): void + { + $this->context->close(); + } + + protected function getContext() + { + return $this->context; + } +} diff --git a/pkg/redis/Tests/Functional/PhpRedisConsumptionUseCasesTest.php b/pkg/redis/Tests/Functional/PhpRedisConsumptionUseCasesTest.php new file mode 100644 index 000000000..073c1aff9 --- /dev/null +++ b/pkg/redis/Tests/Functional/PhpRedisConsumptionUseCasesTest.php @@ -0,0 +1,39 @@ +context = $this->buildPhpRedisContext(); + + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue')); + $this->context->deleteQueue($this->context->createQueue('enqueue.test_queue_reply')); + } + + protected function tearDown(): void + { + $this->context->close(); + } + + protected function getContext() + { + return $this->context; + } +} diff --git a/pkg/redis/Tests/Functional/StubProcessor.php b/pkg/redis/Tests/Functional/StubProcessor.php new file mode 100644 index 000000000..b7c15ada2 --- /dev/null +++ b/pkg/redis/Tests/Functional/StubProcessor.php @@ -0,0 +1,22 @@ +lastProcessedMessage = $message; + + return $this->result; + } +} diff --git a/pkg/redis/Tests/RedisConnectionFactoryConfigTest.php b/pkg/redis/Tests/RedisConnectionFactoryConfigTest.php new file mode 100644 index 000000000..37e831823 --- /dev/null +++ b/pkg/redis/Tests/RedisConnectionFactoryConfigTest.php @@ -0,0 +1,400 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string, null or instance of Enqueue\Redis\Redis'); + + new RedisConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotRedis() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be one of "redis", "rediss", "tcp", "tls", "unix"'); + + new RedisConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new RedisConnectionFactory('foo'); + } + + public function testCouldBeCreatedWithRedisInstance() + { + $redisMock = $this->createMock(Redis::class); + + $factory = new RedisConnectionFactory($redisMock); + $this->assertAttributeSame($redisMock, 'redis', $factory); + + $context = $factory->createContext(); + $this->assertSame($redisMock, $context->getRedis()); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new RedisConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'host' => '127.0.0.1', + 'scheme' => 'redis', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + yield [ + 'redis:', + [ + 'host' => '127.0.0.1', + 'scheme' => 'redis', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + yield [ + [], + [ + 'host' => '127.0.0.1', + 'scheme' => 'redis', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + yield [ + 'unix:/path/to/redis.sock?foo=bar&database=5', + [ + 'host' => '127.0.0.1', + 'scheme' => 'unix', + 'port' => 6379, + 'timeout' => 5., + 'database' => 5, + 'password' => null, + 'scheme_extensions' => [], + 'path' => '/path/to/redis.sock', + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + yield [ + ['dsn' => 'redis://expectedHost:1234/5', 'host' => 'shouldBeOverwrittenHost', 'foo' => 'bar'], + [ + 'host' => 'expectedHost', + 'scheme' => 'redis', + 'port' => 1234, + 'timeout' => 5., + 'database' => 5, + 'password' => null, + 'scheme_extensions' => [], + 'path' => '/5', + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + yield [ + 'redis+predis://localhost:1234/5?foo=bar&persistent=true', + [ + 'host' => 'localhost', + 'scheme' => 'redis', + 'port' => 1234, + 'timeout' => 5., + 'database' => 5, + 'password' => null, + 'scheme_extensions' => ['predis'], + 'path' => '/5', + 'async' => false, + 'persistent' => true, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + // check normal redis connection for php redis extension + yield [ + 'redis+phpredis://localhost:1234?foo=bar', + [ + 'host' => 'localhost', + 'scheme' => 'redis', + 'port' => 1234, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => ['phpredis'], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + // check normal redis connection for predis library + yield [ + 'redis+predis://localhost:1234?foo=bar', + [ + 'host' => 'localhost', + 'scheme' => 'redis', + 'port' => 1234, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => ['predis'], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + // check tls connection for predis library + yield [ + 'rediss+predis://localhost:1234?foo=bar&async=1', + [ + 'host' => 'localhost', + 'scheme' => 'rediss', + 'port' => 1234, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => ['predis'], + 'path' => null, + 'async' => true, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + // check tls connection for predis library + yield [ + 'rediss+phpredis://localhost:1234?foo=bar&async=1', + [ + 'host' => 'localhost', + 'scheme' => 'rediss', + 'port' => 1234, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => ['phpredis'], + 'path' => null, + 'async' => true, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + yield [ + ['host' => 'localhost', 'port' => 1234, 'foo' => 'bar'], + [ + 'host' => 'localhost', + 'scheme' => 'redis', + 'port' => 1234, + 'timeout' => 5., + 'database' => null, + 'password' => null, + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'foo' => 'bar', + 'redelivery_delay' => 300, + ], + ]; + + // heroku redis + yield [ + 'redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', + [ + 'host' => 'ec2-111-1-1-1.compute-1.amazonaws.com', + 'scheme' => 'redis', + 'port' => 111, + 'timeout' => 5., + 'database' => null, + 'password' => 'asdfqwer1234asdf', + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + // password as user + yield [ + 'redis://asdfqwer1234asdf@foo', + [ + 'host' => 'foo', + 'scheme' => 'redis', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'password' => 'asdfqwer1234asdf', + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + // password as query parameter + yield [ + 'redis:?password=asdfqwer1234asdf', + [ + 'host' => '127.0.0.1', + 'scheme' => 'redis', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'password' => 'asdfqwer1234asdf', + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'ssl' => null, + 'redelivery_delay' => 300, + ], + ]; + + // from predis doc + yield [ + 'tls://127.0.0.1?ssl[cafile]=private.pem&ssl[verify_peer]=1', + [ + 'host' => '127.0.0.1', + 'scheme' => 'tls', + 'port' => 6379, + 'timeout' => 5., + 'database' => null, + 'scheme_extensions' => [], + 'path' => null, + 'async' => false, + 'persistent' => false, + 'lazy' => true, + 'read_write_timeout' => null, + 'predis_options' => null, + 'password' => null, + 'ssl' => [ + 'cafile' => 'private.pem', + 'verify_peer' => '1', + ], + 'redelivery_delay' => 300, + ], + ]; + } +} diff --git a/pkg/redis/Tests/RedisConnectionFactoryTest.php b/pkg/redis/Tests/RedisConnectionFactoryTest.php new file mode 100644 index 000000000..1f9b3a259 --- /dev/null +++ b/pkg/redis/Tests/RedisConnectionFactoryTest.php @@ -0,0 +1,33 @@ +assertClassImplements(ConnectionFactory::class, RedisConnectionFactory::class); + } + + public function testShouldCreateLazyContext() + { + $factory = new RedisConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(RedisContext::class, $context); + + $this->assertAttributeEquals(null, 'redis', $context); + self::assertIsCallable($this->readAttribute($context, 'redisFactory')); + } +} diff --git a/pkg/redis/Tests/RedisConsumerTest.php b/pkg/redis/Tests/RedisConsumerTest.php new file mode 100644 index 000000000..56373c18a --- /dev/null +++ b/pkg/redis/Tests/RedisConsumerTest.php @@ -0,0 +1,312 @@ +assertClassImplements(Consumer::class, RedisConsumer::class); + } + + public function testShouldReturnDestinationSetInConstructorOnGetQueue() + { + $destination = new RedisDestination('aQueue'); + + $consumer = new RedisConsumer($this->createContextMock(), $destination); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testShouldAcknowledgeMessage() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('zrem') + ->with('aQueue:reserved', 'reserved-key') + ->willReturn(1) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->once()) + ->method('getRedis') + ->willReturn($redisMock) + ; + + $message = new RedisMessage(); + $message->setReservedKey('reserved-key'); + + $consumer = new RedisConsumer($contextMock, new RedisDestination('aQueue')); + + $consumer->acknowledge($message); + } + + public function testShouldRejectMessage() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('zrem') + ->with('aQueue:reserved', 'reserved-key') + ->willReturn(1) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->once()) + ->method('getRedis') + ->willReturn($redisMock) + ; + + $message = new RedisMessage(); + $message->setReservedKey('reserved-key'); + + $consumer = new RedisConsumer($contextMock, new RedisDestination('aQueue')); + + $consumer->reject($message); + } + + public function testShouldSendSameMessageToDestinationOnReQueue() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('lpush') + ->with('aQueue', '{"body":"text","properties":[],"headers":{"attempts":0}}') + ->willReturn(1) + ; + + $serializer = new JsonSerializer(); + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $contextMock + ->expects($this->any()) + ->method('getSerializer') + ->willReturn($serializer) + ; + + $message = new RedisMessage(); + $message->setBody('text'); + $message->setHeader('attempts', 0); + $message->setReservedKey($serializer->toString($message)); + + $consumer = new RedisConsumer($contextMock, new RedisDestination('aQueue')); + + $consumer->reject($message, true); + } + + public function testShouldCallRedisBRPopAndReturnNullIfNothingInQueueOnReceive() + { + $destination = new RedisDestination('aQueue'); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('brpop') + ->with(['aQueue'], 2) + ->willReturn(null) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('getRedis') + ->willReturn($redisMock) + ; + + $consumer = new RedisConsumer($contextMock, $destination); + + $this->assertNull($consumer->receive(2000)); + } + + public function testShouldCallRedisBRPopAndReturnMessageIfOneInQueueOnReceive() + { + $destination = new RedisDestination('aQueue'); + + $serializer = new JsonSerializer(); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('brpop') + ->with(['aQueue'], 2) + ->willReturn(new RedisResult('aQueue', $serializer->toString(new RedisMessage('aBody')))) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $contextMock + ->expects($this->any()) + ->method('getSerializer') + ->willReturn($serializer) + ; + + $consumer = new RedisConsumer($contextMock, $destination); + + $message = $consumer->receive(2000); + + $this->assertInstanceOf(RedisMessage::class, $message); + $this->assertSame('aBody', $message->getBody()); + } + + public function testShouldCallRedisBRPopSeveralTimesWithFiveSecondTimeoutIfZeroTimeoutIsPassed() + { + $destination = new RedisDestination('aQueue'); + + $expectedTimeout = 5; + + $serializer = new JsonSerializer(); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->at(2)) + ->method('brpop') + ->with(['aQueue'], $expectedTimeout) + ->willReturn(null) + ; + $redisMock + ->expects($this->at(5)) + ->method('brpop') + ->with(['aQueue'], $expectedTimeout) + ->willReturn(null) + ; + $redisMock + ->expects($this->at(8)) + ->method('brpop') + ->with(['aQueue'], $expectedTimeout) + ->willReturn(new RedisResult('aQueue', $serializer->toString(new RedisMessage('aBody')))) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->atLeastOnce()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $contextMock + ->expects($this->atLeastOnce()) + ->method('getSerializer') + ->willReturn($serializer) + ; + + $consumer = new RedisConsumer($contextMock, $destination); + + $message = $consumer->receive(0); + + $this->assertInstanceOf(RedisMessage::class, $message); + $this->assertSame('aBody', $message->getBody()); + } + + public function testShouldCallRedisRPopAndReturnNullIfNothingInQueueOnReceiveNoWait() + { + $destination = new RedisDestination('aQueue'); + + $serializer = new JsonSerializer(); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('rpop') + ->with('aQueue') + ->willReturn(null) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->any()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $contextMock + ->expects($this->any()) + ->method('getSerializer') + ->willReturn($serializer) + ; + + $consumer = new RedisConsumer($contextMock, $destination); + + $this->assertNull($consumer->receiveNoWait()); + } + + public function testShouldCallRedisRPopAndReturnMessageIfOneInQueueOnReceiveNoWait() + { + $destination = new RedisDestination('aQueue'); + + $serializer = new JsonSerializer(); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('rpop') + ->with('aQueue') + ->willReturn(new RedisResult('aQueue', $serializer->toString(new RedisMessage('aBody')))) + ; + + $contextMock = $this->createContextMock(); + $contextMock + ->expects($this->atLeastOnce()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $contextMock + ->expects($this->any()) + ->method('getSerializer') + ->willReturn($serializer) + ; + + $consumer = new RedisConsumer($contextMock, $destination); + + $message = $consumer->receiveNoWait(); + + $this->assertInstanceOf(RedisMessage::class, $message); + $this->assertSame('aBody', $message->getBody()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Redis + */ + private function createRedisMock() + { + return $this->createMock(Redis::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|RedisProducer + */ + private function createProducerMock() + { + return $this->createMock(RedisProducer::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|RedisContext + */ + private function createContextMock() + { + return $this->createMock(RedisContext::class); + } +} diff --git a/pkg/redis/Tests/RedisContextTest.php b/pkg/redis/Tests/RedisContextTest.php new file mode 100644 index 000000000..6395e954e --- /dev/null +++ b/pkg/redis/Tests/RedisContextTest.php @@ -0,0 +1,228 @@ +assertClassImplements(Context::class, RedisContext::class); + } + + public function testThrowIfNeitherRedisNorFactoryGiven() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The $redis argument must be either Enqueue\Redis\Redis or callable that returns Enqueue\Redis\Redis once called.'); + new RedisContext(new \stdClass(), 300); + } + + public function testShouldAllowCreateEmptyMessage() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $message = $context->createMessage(); + + $this->assertInstanceOf(RedisMessage::class, $message); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testShouldAllowCreateCustomMessage() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $message = $context->createMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $this->assertInstanceOf(RedisMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testShouldCreateQueue() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $queue = $context->createQueue('aQueue'); + + $this->assertInstanceOf(RedisDestination::class, $queue); + $this->assertSame('aQueue', $queue->getQueueName()); + } + + public function testShouldAllowCreateTopic() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $topic = $context->createTopic('aTopic'); + + $this->assertInstanceOf(RedisDestination::class, $topic); + $this->assertSame('aTopic', $topic->getTopicName()); + } + + public function testThrowNotImplementedOnCreateTmpQueueCall() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + public function testShouldCreateProducer() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $producer = $context->createProducer(); + + $this->assertInstanceOf(RedisProducer::class, $producer); + } + + public function testShouldThrowIfNotRedisDestinationGivenOnCreateConsumer() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Redis\RedisDestination but got Enqueue\Null\NullQueue.'); + $consumer = $context->createConsumer(new NullQueue('aQueue')); + + $this->assertInstanceOf(RedisConsumer::class, $consumer); + } + + public function testShouldCreateConsumer() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $queue = $context->createQueue('aQueue'); + + $consumer = $context->createConsumer($queue); + + $this->assertInstanceOf(RedisConsumer::class, $consumer); + } + + public function testShouldCallRedisDisconnectOnClose() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('disconnect') + ; + + $context = new RedisContext($redisMock, 300); + + $context->close(); + } + + public function testThrowIfNotRedisDestinationGivenOnDeleteQueue() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->never()) + ->method('del') + ; + + $context = new RedisContext($redisMock, 300); + + $this->expectException(InvalidDestinationException::class); + $context->deleteQueue(new NullQueue('aQueue')); + } + + public function testShouldAllowDeleteQueue() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->at(0)) + ->method('del') + ->with('aQueueName') + ; + $redisMock + ->expects($this->at(1)) + ->method('del') + ->with('aQueueName:delayed') + ; + $redisMock + ->expects($this->at(2)) + ->method('del') + ->with('aQueueName:reserved') + ; + + $context = new RedisContext($redisMock, 300); + + $queue = $context->createQueue('aQueueName'); + + $context->deleteQueue($queue); + } + + public function testThrowIfNotRedisDestinationGivenOnDeleteTopic() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->never()) + ->method('del') + ; + + $context = new RedisContext($redisMock, 300); + + $this->expectException(InvalidDestinationException::class); + $context->deleteTopic(new NullTopic('aTopic')); + } + + public function testShouldAllowDeleteTopic() + { + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->at(0)) + ->method('del') + ->with('aTopicName') + ; + $redisMock + ->expects($this->at(1)) + ->method('del') + ->with('aTopicName:delayed') + ; + $redisMock + ->expects($this->at(2)) + ->method('del') + ->with('aTopicName:reserved') + ; + + $context = new RedisContext($redisMock, 300); + + $topic = $context->createTopic('aTopicName'); + + $context->deleteQueue($topic); + } + + public function testShouldReturnExpectedSubscriptionConsumerInstance() + { + $context = new RedisContext($this->createRedisMock(), 300); + + $this->assertInstanceOf(RedisSubscriptionConsumer::class, $context->createSubscriptionConsumer()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Redis + */ + private function createRedisMock() + { + return $this->createMock(Redis::class); + } +} diff --git a/pkg/redis/Tests/RedisDestinationTest.php b/pkg/redis/Tests/RedisDestinationTest.php new file mode 100644 index 000000000..4e73849fc --- /dev/null +++ b/pkg/redis/Tests/RedisDestinationTest.php @@ -0,0 +1,28 @@ +assertClassImplements(Topic::class, RedisDestination::class); + $this->assertClassImplements(Queue::class, RedisDestination::class); + } + + public function testShouldReturnNameSetInConstructor() + { + $destination = new RedisDestination('aDestinationName'); + + $this->assertSame('aDestinationName', $destination->getName()); + $this->assertSame('aDestinationName', $destination->getQueueName()); + $this->assertSame('aDestinationName', $destination->getTopicName()); + } +} diff --git a/pkg/redis/Tests/RedisMessageTest.php b/pkg/redis/Tests/RedisMessageTest.php new file mode 100644 index 000000000..5b1e42fe2 --- /dev/null +++ b/pkg/redis/Tests/RedisMessageTest.php @@ -0,0 +1,61 @@ +assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new RedisMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } + + public function testShouldSetCorrelationIdAsHeader() + { + $message = new RedisMessage(); + $message->setCorrelationId('the-correlation-id'); + + $this->assertSame(['correlation_id' => 'the-correlation-id'], $message->getHeaders()); + } + + public function testCouldSetMessageIdAsHeader() + { + $message = new RedisMessage(); + $message->setMessageId('the-message-id'); + + $this->assertSame(['message_id' => 'the-message-id'], $message->getHeaders()); + } + + public function testCouldSetTimestampAsHeader() + { + $message = new RedisMessage(); + $message->setTimestamp(12345); + + $this->assertSame(['timestamp' => 12345], $message->getHeaders()); + } + + public function testShouldSetReplyToAsHeader() + { + $message = new RedisMessage(); + $message->setReplyTo('theQueueName'); + + $this->assertSame(['reply_to' => 'theQueueName'], $message->getHeaders()); + } +} diff --git a/pkg/redis/Tests/RedisProducerTest.php b/pkg/redis/Tests/RedisProducerTest.php new file mode 100644 index 000000000..40e03bae2 --- /dev/null +++ b/pkg/redis/Tests/RedisProducerTest.php @@ -0,0 +1,140 @@ +assertClassImplements(Producer::class, RedisProducer::class); + } + + public function testThrowIfDestinationNotRedisDestinationOnSend() + { + $producer = new RedisProducer($this->createContextMock()); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Redis\RedisDestination but got Enqueue\Null\NullQueue.'); + $producer->send(new NullQueue('aQueue'), new RedisMessage()); + } + + public function testThrowIfMessageNotRedisMessageOnSend() + { + $producer = new RedisProducer($this->createContextMock()); + + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Redis\RedisMessage but it is Enqueue\Null\NullMessage.'); + $producer->send(new RedisDestination('aQueue'), new NullMessage()); + } + + public function testShouldCallLPushOnSend() + { + $destination = new RedisDestination('aDestination'); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('lpush') + ->willReturnCallback(function (string $key, string $value) { + $this->assertSame('aDestination', $key); + + $message = json_decode($value, true); + + $this->assertArrayHasKey('body', $message); + $this->assertArrayHasKey('properties', $message); + $this->assertArrayHasKey('headers', $message); + $this->assertNotEmpty($message['headers']['message_id']); + $this->assertSame(0, $message['headers']['attempts']); + + return 1; + }) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $context + ->expects($this->once()) + ->method('getSerializer') + ->willReturn(new JsonSerializer()) + ; + + $producer = new RedisProducer($context); + + $producer->send($destination, new RedisMessage()); + } + + /** + * Tests if Redis::zadd is called with the expected 'score' (used as delivery timestamp). + * + * @depends testShouldCallLPushOnSend + */ + public function testShouldCallZaddOnSendWithDeliveryDelay() + { + $destination = new RedisDestination('aDestination'); + + $redisMock = $this->createRedisMock(); + $redisMock + ->expects($this->once()) + ->method('zadd') + ->with( + 'aDestination:delayed', + $this->isJson(), + $this->equalTo(time() + 5) + ) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getRedis') + ->willReturn($redisMock) + ; + $context + ->expects($this->once()) + ->method('getSerializer') + ->willReturn(new JsonSerializer()) + ; + + $message = new RedisMessage(); + $message->setDeliveryDelay(5000); // 5 seconds in milliseconds + + $producer = new RedisProducer($context); + $producer->send($destination, $message); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|RedisContext + */ + private function createContextMock() + { + return $this->createMock(RedisContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|Redis + */ + private function createRedisMock() + { + return $this->createMock(Redis::class); + } +} diff --git a/pkg/redis/Tests/RedisSubscriptionConsumerTest.php b/pkg/redis/Tests/RedisSubscriptionConsumerTest.php new file mode 100644 index 000000000..8d00fcc14 --- /dev/null +++ b/pkg/redis/Tests/RedisSubscriptionConsumerTest.php @@ -0,0 +1,175 @@ +assertTrue($rc->implementsInterface(SubscriptionConsumer::class)); + } + + public function testShouldAddConsumerAndCallbackToSubscribersPropertyOnSubscribe() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + + $this->assertAttributeSame([ + 'foo_queue' => [$fooConsumer, $fooCallback], + 'bar_queue' => [$barConsumer, $barCallback], + ], 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTrySubscribeAnotherConsumerToAlreadySubscribedQueue() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $barCallback = function () {}; + $barConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('There is a consumer subscribed to queue: "foo_queue"'); + $subscriptionConsumer->subscribe($barConsumer, $barCallback); + } + + /** + * @doesNotPerformAssertions + */ + public function testShouldAllowSubscribeSameConsumerAndCallbackSecondTime() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $fooCallback = function () {}; + $fooConsumer = $this->createConsumerStub('foo_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + $subscriptionConsumer->subscribe($fooConsumer, $fooCallback); + } + + public function testShouldRemoveSubscribedConsumerOnUnsubscribeCall() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $fooConsumer = $this->createConsumerStub('foo_queue'); + $barConsumer = $this->createConsumerStub('bar_queue'); + + $subscriptionConsumer->subscribe($fooConsumer, function () {}); + $subscriptionConsumer->subscribe($barConsumer, function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($fooConsumer); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedQueueName() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('bar_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldDoNothingIfTryUnsubscribeNotSubscribedConsumer() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + + // guard + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribe($this->createConsumerStub('foo_queue')); + + $this->assertAttributeCount(1, 'subscribers', $subscriptionConsumer); + } + + public function testShouldRemoveAllSubscriberOnUnsubscribeAllCall() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $subscriptionConsumer->subscribe($this->createConsumerStub('foo_queue'), function () {}); + $subscriptionConsumer->subscribe($this->createConsumerStub('bar_queue'), function () {}); + + // guard + $this->assertAttributeCount(2, 'subscribers', $subscriptionConsumer); + + $subscriptionConsumer->unsubscribeAll(); + + $this->assertAttributeCount(0, 'subscribers', $subscriptionConsumer); + } + + public function testThrowsIfTryConsumeWithoutSubscribers() + { + $subscriptionConsumer = new RedisSubscriptionConsumer($this->createRedisContextMock()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('No subscribers'); + $subscriptionConsumer->consume(); + } + + /** + * @return RedisContext|\PHPUnit\Framework\MockObject\MockObject + */ + private function createRedisContextMock() + { + return $this->createMock(RedisContext::class); + } + + /** + * @param mixed|null $queueName + * + * @return Consumer|\PHPUnit\Framework\MockObject\MockObject + */ + private function createConsumerStub($queueName = null) + { + $queueMock = $this->createMock(Queue::class); + $queueMock + ->expects($this->any()) + ->method('getQueueName') + ->willReturn($queueName); + + $consumerMock = $this->createMock(RedisConsumer::class); + $consumerMock + ->expects($this->any()) + ->method('getQueue') + ->willReturn($queueMock) + ; + + return $consumerMock; + } +} diff --git a/pkg/redis/Tests/Spec/JsonSerializerTest.php b/pkg/redis/Tests/Spec/JsonSerializerTest.php new file mode 100644 index 000000000..a15859d35 --- /dev/null +++ b/pkg/redis/Tests/Spec/JsonSerializerTest.php @@ -0,0 +1,71 @@ +assertClassImplements(Serializer::class, JsonSerializer::class); + } + + public function testShouldConvertMessageToJsonString() + { + $serializer = new JsonSerializer(); + + $message = new RedisMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $json = $serializer->toString($message); + + $this->assertSame('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}', $json); + } + + public function testThrowIfFailedToEncodeMessageToJson() + { + $serializer = new JsonSerializer(); + + $resource = fopen(__FILE__, 'r'); + + // guard + $this->assertIsResource($resource); + + $message = new RedisMessage('theBody', ['aProp' => $resource]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toString($message); + } + + public function testShouldConvertJsonStringToMessage() + { + $serializer = new JsonSerializer(); + + $message = $serializer->toMessage('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}'); + + $this->assertInstanceOf(RedisMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testThrowIfFailedToDecodeJsonToMessage() + { + $serializer = new JsonSerializer(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toMessage('{]'); + } +} diff --git a/pkg/redis/Tests/Spec/RedisConnectionFactoryTest.php b/pkg/redis/Tests/Spec/RedisConnectionFactoryTest.php new file mode 100644 index 000000000..f15282f11 --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisConnectionFactoryTest.php @@ -0,0 +1,17 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisMessageTest.php b/pkg/redis/Tests/Spec/RedisMessageTest.php new file mode 100644 index 000000000..b0cc828e9 --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisMessageTest.php @@ -0,0 +1,17 @@ +buildPhpRedisContext()->createProducer(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisQueueTest.php b/pkg/redis/Tests/Spec/RedisQueueTest.php new file mode 100644 index 000000000..a8cd3b442 --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisQueueTest.php @@ -0,0 +1,17 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromQueueTest.php b/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..7bb61ab5d --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromQueueTest.php @@ -0,0 +1,20 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromTopicTest.php b/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..37545f032 --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSendToAndReceiveFromTopicTest.php @@ -0,0 +1,20 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromQueueTest.php b/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..01aea7ff1 --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,20 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromTopicTest.php b/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..2c8fbac7a --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,20 @@ +buildPhpRedisContext(); + } +} diff --git a/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php new file mode 100644 index 000000000..78d33045f --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeFromAllSubscribedQueuesTest.php @@ -0,0 +1,38 @@ +buildPhpRedisContext(); + } + + /** + * @param RedisContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var RedisDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->deleteQueue($queue); + + return $queue; + } +} diff --git a/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeUntilUnsubscribedTest.php b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeUntilUnsubscribedTest.php new file mode 100644 index 000000000..84872093b --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerConsumeUntilUnsubscribedTest.php @@ -0,0 +1,38 @@ +buildPhpRedisContext(); + } + + /** + * @param RedisContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var RedisDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->purgeQueue($queue); + + return $queue; + } +} diff --git a/pkg/redis/Tests/Spec/RedisSubscriptionConsumerStopOnFalseTest.php b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerStopOnFalseTest.php new file mode 100644 index 000000000..490d58eab --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisSubscriptionConsumerStopOnFalseTest.php @@ -0,0 +1,38 @@ +buildPhpRedisContext(); + } + + /** + * @param RedisContext $context + */ + protected function createQueue(Context $context, $queueName) + { + /** @var RedisDestination $queue */ + $queue = parent::createQueue($context, $queueName); + $context->getRedis()->del($queueName); + + return $queue; + } +} diff --git a/pkg/redis/Tests/Spec/RedisTopicTest.php b/pkg/redis/Tests/Spec/RedisTopicTest.php new file mode 100644 index 000000000..da94ffa1b --- /dev/null +++ b/pkg/redis/Tests/Spec/RedisTopicTest.php @@ -0,0 +1,17 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/simple-client/.gitattributes b/pkg/simple-client/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/simple-client/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/simple-client/.github/workflows/ci.yml b/pkg/simple-client/.github/workflows/ci.yml new file mode 100644 index 000000000..604442a2f --- /dev/null +++ b/pkg/simple-client/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: php Tests/fix_composer_json.php + + - uses: "ramsey/composer-install@v3" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/simple-client/.gitignore b/pkg/simple-client/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/simple-client/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/simple-client/LICENSE b/pkg/simple-client/LICENSE new file mode 100644 index 000000000..70fa75252 --- /dev/null +++ b/pkg/simple-client/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/pkg/simple-client/README.md b/pkg/simple-client/README.md new file mode 100644 index 000000000..8ecb67059 --- /dev/null +++ b/pkg/simple-client/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Message Queue. Simple client + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/simple-client/ci.yml?branch=master)](https://github.com/php-enqueue/simple-client/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/simple-client/d/total.png)](https://packagist.org/packages/enqueue/simple-client) +[![Latest Stable Version](https://poser.pugx.org/enqueue/simple-client/version.png)](https://packagist.org/packages/enqueue/simple-client) + +The simple client takes Enqueue client classes and Symfony components and combines it to easy to use facade called `SimpleClient`. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/simple-client/SimpleClient.php b/pkg/simple-client/SimpleClient.php new file mode 100644 index 000000000..bbfd8b91b --- /dev/null +++ b/pkg/simple-client/SimpleClient.php @@ -0,0 +1,349 @@ + [ + * 'dsn' => 'amqps://guest:guest@localhost:5672/%2f', + * 'ssl_cacert' => '/a/dir/cacert.pem', + * 'ssl_cert' => '/a/dir/cert.pem', + * 'ssl_key' => '/a/dir/key.pem', + * ] + * + * with custom connection factory class + * + * $config = [ + * 'transport' => [ + * 'dsn' => 'amqps://guest:guest@localhost:5672/%2f', + * 'connection_factory_class' => 'aCustomConnectionFactory', + * // other options available options are factory_class and factory_service + * ] + * + * The client config + * + * $config = [ + * 'transport' => 'null:', + * 'client' => [ + * 'prefix' => 'enqueue', + * 'separator' => '.', + * 'app_name' => 'app', + * 'router_topic' => 'router', + * 'router_queue' => 'default', + * 'default_queue' => 'default', + * 'redelivered_delay_time' => 0 + * ], + * 'extensions' => [ + * 'signal_extension' => true, + * 'reply_extension' => true, + * ] + * ] + * + * @param string|array $config + */ + public function __construct($config, ?LoggerInterface $logger = null) + { + if (is_string($config)) { + $config = [ + 'transport' => $config, + 'client' => true, + ]; + } + + $this->logger = $logger ?: new NullLogger(); + + $this->build(['enqueue' => $config]); + } + + /** + * @param callable|Processor $processor + */ + public function bindTopic(string $topic, $processor, ?string $processorName = null): void + { + if (is_callable($processor)) { + $processor = new CallbackProcessor($processor); + } + + if (false == $processor instanceof Processor) { + throw new \LogicException('The processor must be either callable or instance of Processor'); + } + + $processorName = $processorName ?: uniqid($processor::class); + + $this->driver->getRouteCollection()->add(new Route($topic, Route::TOPIC, $processorName)); + $this->processorRegistry->add($processorName, $processor); + } + + /** + * @param callable|Processor $processor + */ + public function bindCommand(string $command, $processor, ?string $processorName = null): void + { + if (is_callable($processor)) { + $processor = new CallbackProcessor($processor); + } + + if (false == $processor instanceof Processor) { + throw new \LogicException('The processor must be either callable or instance of Processor'); + } + + $processorName = $processorName ?: uniqid($processor::class); + + $this->driver->getRouteCollection()->add(new Route($command, Route::COMMAND, $processorName)); + $this->processorRegistry->add($processorName, $processor); + } + + /** + * @param string|array|\JsonSerializable|Message $message + */ + public function sendCommand(string $command, $message, bool $needReply = false): ?Promise + { + return $this->producer->sendCommand($command, $message, $needReply); + } + + /** + * @param string|array|Message $message + */ + public function sendEvent(string $topic, $message): void + { + $this->producer->sendEvent($topic, $message); + } + + public function consume(?ExtensionInterface $runtimeExtension = null): void + { + $this->setupBroker(); + + $boundQueues = []; + + $routerQueue = $this->getDriver()->createQueue($this->getDriver()->getConfig()->getRouterQueue()); + $this->queueConsumer->bind($routerQueue, $this->delegateProcessor); + $boundQueues[$routerQueue->getQueueName()] = true; + + foreach ($this->driver->getRouteCollection()->all() as $route) { + $queue = $this->getDriver()->createRouteQueue($route); + if (array_key_exists($queue->getQueueName(), $boundQueues)) { + continue; + } + + $this->queueConsumer->bind($queue, $this->delegateProcessor); + + $boundQueues[$queue->getQueueName()] = true; + } + + $this->queueConsumer->consume($runtimeExtension); + } + + public function getQueueConsumer(): QueueConsumerInterface + { + return $this->queueConsumer; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function getProducer(bool $setupBroker = false): ProducerInterface + { + $setupBroker && $this->setupBroker(); + + return $this->producer; + } + + public function getDelegateProcessor(): DelegateProcessor + { + return $this->delegateProcessor; + } + + public function setupBroker(): void + { + $this->getDriver()->setupBroker(); + } + + public function build(array $configs): void + { + $configProcessor = new ConfigProcessor(); + $simpleClientConfig = $configProcessor->process($this->createConfiguration(), $configs); + + if (isset($simpleClientConfig['transport']['factory_service'])) { + throw new \LogicException('transport.factory_service option is not supported by simple client'); + } + if (isset($simpleClientConfig['transport']['factory_class'])) { + throw new \LogicException('transport.factory_class option is not supported by simple client'); + } + if (isset($simpleClientConfig['transport']['connection_factory_class'])) { + throw new \LogicException('transport.connection_factory_class option is not supported by simple client'); + } + + $connectionFactoryFactory = new ConnectionFactoryFactory(); + $connection = $connectionFactoryFactory->create($simpleClientConfig['transport']); + + $clientExtensions = new ClientChainExtensions([]); + + $config = new Config( + $simpleClientConfig['client']['prefix'], + $simpleClientConfig['client']['separator'], + $simpleClientConfig['client']['app_name'], + $simpleClientConfig['client']['router_topic'], + $simpleClientConfig['client']['router_queue'], + $simpleClientConfig['client']['default_queue'], + 'enqueue.client.router_processor', + $simpleClientConfig['transport'], + [] + ); + + $routeCollection = new RouteCollection([]); + $driverFactory = new DriverFactory(); + + $driver = $driverFactory->create( + $connection, + $config, + $routeCollection + ); + + $rpcFactory = new RpcFactory($driver->getContext()); + + $producer = new Producer($driver, $rpcFactory, $clientExtensions); + + $processorRegistry = new ArrayProcessorRegistry([]); + + $delegateProcessor = new DelegateProcessor($processorRegistry); + + // consumption extensions + $consumptionExtensions = []; + if ($simpleClientConfig['client']['redelivered_delay_time']) { + $consumptionExtensions[] = new DelayRedeliveredMessageExtension($driver, $simpleClientConfig['client']['redelivered_delay_time']); + } + + if ($simpleClientConfig['extensions']['signal_extension']) { + $consumptionExtensions[] = new SignalExtension(); + } + + if ($simpleClientConfig['extensions']['reply_extension']) { + $consumptionExtensions[] = new ReplyExtension(); + } + + $consumptionExtensions[] = new SetRouterPropertiesExtension($driver); + $consumptionExtensions[] = new LogExtension(); + + $consumptionChainExtension = new ConsumptionChainExtension($consumptionExtensions); + $queueConsumer = new QueueConsumer($driver->getContext(), $consumptionChainExtension, [], $this->logger); + + $routerProcessor = new RouterProcessor($driver); + + $processorRegistry->add($config->getRouterProcessor(), $routerProcessor); + + $this->driver = $driver; + $this->producer = $producer; + $this->queueConsumer = $queueConsumer; + $this->delegateProcessor = $delegateProcessor; + $this->processorRegistry = $processorRegistry; + } + + private function createConfiguration(): NodeInterface + { + if (method_exists(TreeBuilder::class, 'getRootNode')) { + $tb = new TreeBuilder('enqueue'); + $rootNode = $tb->getRootNode(); + } else { + $tb = new TreeBuilder(); + $rootNode = $tb->root('enqueue'); + } + + $rootNode + ->beforeNormalization() + ->ifEmpty()->then(function () { + return ['transport' => ['dsn' => 'null:']]; + }); + + $rootNode + ->append(TransportFactory::getConfiguration()) + ->append(TransportFactory::getQueueConsumerConfiguration()) + ->append(ClientFactory::getConfiguration(false)) + ; + + $rootNode->children() + ->arrayNode('extensions')->addDefaultsIfNotSet()->children() + ->booleanNode('signal_extension')->defaultValue(function_exists('pcntl_signal_dispatch'))->end() + ->booleanNode('reply_extension')->defaultTrue()->end() + ->end() + ; + + return $tb->buildTree(); + } +} diff --git a/pkg/simple-client/Tests/Functional/SimpleClientTest.php b/pkg/simple-client/Tests/Functional/SimpleClientTest.php new file mode 100644 index 000000000..ce75457af --- /dev/null +++ b/pkg/simple-client/Tests/Functional/SimpleClientTest.php @@ -0,0 +1,212 @@ + [[ + 'transport' => getenv('AMQP_DSN'), + ], '+1sec']; + + yield 'dbal_dsn' => [[ + 'transport' => getenv('DOCTRINE_DSN'), + ], '+1sec']; + + yield 'rabbitmq_stomp' => [[ + 'transport' => [ + 'dsn' => getenv('RABITMQ_STOMP_DSN'), + 'lazy' => false, + 'management_plugin_installed' => true, + ], + ], '+1sec']; + + yield 'predis_dsn' => [[ + 'transport' => [ + 'dsn' => getenv('PREDIS_DSN'), + 'lazy' => false, + ], + ], '+1sec']; + + yield 'fs_dsn' => [[ + 'transport' => 'file://'.sys_get_temp_dir(), + ], '+1sec']; + + yield 'sqs' => [[ + 'transport' => [ + 'dsn' => getenv('SQS_DSN'), + ], + ], '+1sec']; + + yield 'mongodb_dsn' => [[ + 'transport' => getenv('MONGO_DSN'), + ], '+1sec']; + } + + public function testShouldWorkWithStringDsnConstructorArgument() + { + $actualMessage = null; + + $client = new SimpleClient(getenv('AMQP_DSN')); + + $client->bindTopic('foo_topic', function (Message $message) use (&$actualMessage) { + $actualMessage = $message; + + return Result::ACK; + }); + + $client->setupBroker(); + $this->purgeQueue($client); + + $client->sendEvent('foo_topic', 'Hello there!'); + + $client->getQueueConsumer()->setReceiveTimeout(200); + $client->consume(new ChainExtension([ + new LimitConsumptionTimeExtension(new \DateTime('+1sec')), + new LimitConsumedMessagesExtension(2), + ])); + + $this->assertInstanceOf(Message::class, $actualMessage); + $this->assertSame('Hello there!', $actualMessage->getBody()); + } + + /** + * @dataProvider transportConfigDataProvider + */ + public function testSendEventWithOneSubscriber($config, string $timeLimit) + { + $actualMessage = null; + + $config['client'] = [ + 'prefix' => str_replace('.', '', uniqid('enqueue', true)), + 'app_name' => 'simple_client', + 'router_topic' => 'test', + 'router_queue' => 'test', + 'default_queue' => 'test', + ]; + + $client = new SimpleClient($config); + + $client->bindTopic('foo_topic', function (Message $message) use (&$actualMessage) { + $actualMessage = $message; + + return Result::ACK; + }); + + $client->setupBroker(); + $this->purgeQueue($client); + + $client->sendEvent('foo_topic', 'Hello there!'); + + $client->getQueueConsumer()->setReceiveTimeout(200); + $client->consume(new ChainExtension([ + new LimitConsumptionTimeExtension(new \DateTime($timeLimit)), + new LimitConsumedMessagesExtension(2), + ])); + + $this->assertInstanceOf(Message::class, $actualMessage); + $this->assertSame('Hello there!', $actualMessage->getBody()); + } + + /** + * @dataProvider transportConfigDataProvider + */ + public function testSendEventWithTwoSubscriber($config, string $timeLimit) + { + $received = 0; + + $config['client'] = [ + 'prefix' => str_replace('.', '', uniqid('enqueue', true)), + 'app_name' => 'simple_client', + 'router_topic' => 'test', + 'router_queue' => 'test', + 'default_queue' => 'test', + ]; + + $client = new SimpleClient($config); + + $client->bindTopic('foo_topic', function () use (&$received) { + ++$received; + + return Result::ACK; + }); + $client->bindTopic('foo_topic', function () use (&$received) { + ++$received; + + return Result::ACK; + }); + + $client->setupBroker(); + $this->purgeQueue($client); + + $client->sendEvent('foo_topic', 'Hello there!'); + $client->getQueueConsumer()->setReceiveTimeout(200); + $client->consume(new ChainExtension([ + new LimitConsumptionTimeExtension(new \DateTime($timeLimit)), + new LimitConsumedMessagesExtension(3), + ])); + + $this->assertSame(2, $received); + } + + /** + * @dataProvider transportConfigDataProvider + */ + public function testSendCommand($config, string $timeLimit) + { + $received = 0; + + $config['client'] = [ + 'prefix' => str_replace('.', '', uniqid('enqueue', true)), + 'app_name' => 'simple_client', + 'router_topic' => 'test', + 'router_queue' => 'test', + 'default_queue' => 'test', + ]; + + $client = new SimpleClient($config); + + $client->bindCommand('foo_command', function () use (&$received) { + ++$received; + + return Result::ACK; + }); + + $client->setupBroker(); + $this->purgeQueue($client); + + $client->sendCommand('foo_command', 'Hello there!'); + $client->getQueueConsumer()->setReceiveTimeout(200); + $client->consume(new ChainExtension([ + new LimitConsumptionTimeExtension(new \DateTime($timeLimit)), + new LimitConsumedMessagesExtension(1), + ])); + + $this->assertSame(1, $received); + } + + protected function purgeQueue(SimpleClient $client): void + { + $driver = $client->getDriver(); + + $queue = $driver->createQueue($driver->getConfig()->getDefaultQueue()); + + try { + $client->getDriver()->getContext()->purgeQueue($queue); + } catch (PurgeQueueNotSupportedException $e) { + } + } +} diff --git a/pkg/simple-client/Tests/fix_composer_json.php b/pkg/simple-client/Tests/fix_composer_json.php new file mode 100644 index 000000000..01f73c95e --- /dev/null +++ b/pkg/simple-client/Tests/fix_composer_json.php @@ -0,0 +1,9 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Resources + ./Tests + + + + diff --git a/pkg/sns/.gitattributes b/pkg/sns/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/sns/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/sns/.github/workflows/ci.yml b/pkg/sns/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/sns/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/sns/.gitignore b/pkg/sns/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/sns/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/sns/LICENSE b/pkg/sns/LICENSE new file mode 100644 index 000000000..20211e5fd --- /dev/null +++ b/pkg/sns/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Max Kotliar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/sns/README.md b/pkg/sns/README.md new file mode 100644 index 000000000..bfc7a4012 --- /dev/null +++ b/pkg/sns/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SNS Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/sns/ci.yml?branch=master)](https://github.com/php-enqueue/sns/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/sns/d/total.png)](https://packagist.org/packages/enqueue/sns) +[![Latest Stable Version](https://poser.pugx.org/enqueue/sns/version.png)](https://packagist.org/packages/enqueue/sns) + +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SNS](https://aws.amazon.com/sns/) service. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/sns/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/sns/SnsClient.php b/pkg/sns/SnsClient.php new file mode 100644 index 000000000..7c4a81693 --- /dev/null +++ b/pkg/sns/SnsClient.php @@ -0,0 +1,145 @@ +inputClient = $inputClient; + } + + public function createTopic(array $args): Result + { + return $this->callApi('createTopic', $args); + } + + public function deleteTopic(string $topicArn): Result + { + return $this->callApi('DeleteTopic', [ + 'TopicArn' => $topicArn, + ]); + } + + public function publish(array $args): Result + { + return $this->callApi('publish', $args); + } + + public function subscribe(array $args): Result + { + return $this->callApi('subscribe', $args); + } + + public function unsubscribe(array $args): Result + { + return $this->callApi('unsubscribe', $args); + } + + public function setSubscriptionAttributes(array $args): Result + { + return $this->callApi('setSubscriptionAttributes', $args); + } + + public function listSubscriptionsByTopic(array $args): Result + { + return $this->callApi('ListSubscriptionsByTopic', $args); + } + + public function getAWSClient(): AwsSnsClient + { + $this->resolveClient(); + + if ($this->singleClient) { + return $this->singleClient; + } + + if ($this->multiClient) { + $mr = new \ReflectionMethod($this->multiClient, 'getClientFromPool'); + $mr->setAccessible(true); + $singleClient = $mr->invoke($this->multiClient, $this->multiClient->getRegion()); + $mr->setAccessible(false); + + return $singleClient; + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function callApi(string $name, array $args): Result + { + $this->resolveClient(); + + if ($this->singleClient) { + if (false == empty($args['@region'])) { + throw new \LogicException('Cannot send message to another region because transport is configured with single aws client'); + } + + unset($args['@region']); + + return call_user_func([$this->singleClient, $name], $args); + } + + if ($this->multiClient) { + return call_user_func([$this->multiClient, $name], $args); + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function resolveClient(): void + { + if ($this->singleClient || $this->multiClient) { + return; + } + + $client = $this->inputClient; + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } elseif ($client instanceof AwsSnsClient) { + $this->singleClient = $client; + + return; + } elseif (is_callable($client)) { + $client = call_user_func($client); + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } + if ($client instanceof AwsSnsClient) { + $this->singleClient = $client; + + return; + } + } + + throw new \LogicException(sprintf('The input client must be an instance of "%s" or "%s" or a callable that returns one of those. Got "%s"', AwsSnsClient::class, MultiRegionClient::class, is_object($client) ? $client::class : gettype($client))); + } +} diff --git a/pkg/sns/SnsConnectionFactory.php b/pkg/sns/SnsConnectionFactory.php new file mode 100644 index 000000000..8a815abad --- /dev/null +++ b/pkg/sns/SnsConnectionFactory.php @@ -0,0 +1,159 @@ + null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'secret' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'token' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'region' => null, (string, required) Region to connect to. See http://docs.aws.amazon.com/general/latest/gr/rande.html for a list of available regions. + * 'version' => '2012-11-05', (string, required) The version of the webservice to utilize + * 'lazy' => true, Enable lazy connection (boolean) + * 'endpoint' => null, (string, default=null) The full URI of the webservice. This is only required when connecting to a custom endpoint e.g. localstack + * 'topic_arns' => [], (array) The list of existing topic arns: key - topic name; value - arn + * ]. + * + * or + * + * sns: + * sns::?key=aKey&secret=aSecret&token=aToken + * + * @param array|string|SnsClient|null $config + */ + public function __construct($config = 'sns:') + { + if ($config instanceof AwsSnsClient) { + $this->client = new SnsClient($config); + $this->config = ['lazy' => false] + $this->defaultConfig(); + + return; + } + + if (empty($config)) { + $config = []; + } elseif (\is_string($config)) { + $config = $this->parseDsn($config); + } elseif (\is_array($config)) { + if (\array_key_exists('dsn', $config)) { + $config = \array_replace_recursive($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException(\sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSnsClient::class)); + } + + $this->config = \array_replace($this->defaultConfig(), $config); + } + + /** + * @return SnsContext + */ + public function createContext(): Context + { + return new SnsContext($this->establishConnection(), $this->config); + } + + private function establishConnection(): SnsClient + { + if ($this->client) { + return $this->client; + } + + $config = [ + 'version' => $this->config['version'], + 'region' => $this->config['region'], + ]; + + if (isset($this->config['endpoint'])) { + $config['endpoint'] = $this->config['endpoint']; + } + + if (isset($this->config['profile'])) { + $config['profile'] = $this->config['profile']; + } + + if ($this->config['key'] && $this->config['secret']) { + $config['credentials'] = [ + 'key' => $this->config['key'], + 'secret' => $this->config['secret'], + ]; + + if ($this->config['token']) { + $config['credentials']['token'] = $this->config['token']; + } + } + + if (isset($this->config['http'])) { + $config['http'] = $this->config['http']; + } + + $establishConnection = function () use ($config) { + return (new Sdk(['Sns' => $config]))->createMultiRegionSns(); + }; + + $this->client = $this->config['lazy'] ? + new SnsClient($establishConnection) : + new SnsClient($establishConnection()) + ; + + return $this->client; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('sns' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(\sprintf('The given scheme protocol "%s" is not supported. It must be "sns"', $dsn->getSchemeProtocol())); + } + + return \array_filter(\array_replace($dsn->getQuery(), [ + 'key' => $dsn->getString('key'), + 'secret' => $dsn->getString('secret'), + 'token' => $dsn->getString('token'), + 'region' => $dsn->getString('region'), + 'version' => $dsn->getString('version'), + 'lazy' => $dsn->getBool('lazy'), + 'endpoint' => $dsn->getString('endpoint'), + 'topic_arns' => $dsn->getArray('topic_arns', [])->toArray(), + 'http' => $dsn->getArray('http', [])->toArray(), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ]; + } +} diff --git a/pkg/sns/SnsContext.php b/pkg/sns/SnsContext.php new file mode 100644 index 000000000..2e19164d9 --- /dev/null +++ b/pkg/sns/SnsContext.php @@ -0,0 +1,214 @@ +client = $client; + $this->config = $config; + $this->topicArns = $config['topic_arns'] ?? []; + } + + /** + * @return SnsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new SnsMessage($body, $properties, $headers); + } + + /** + * @return SnsDestination + */ + public function createTopic(string $topicName): Topic + { + return new SnsDestination($topicName); + } + + /** + * @return SnsDestination + */ + public function createQueue(string $queueName): Queue + { + return new SnsDestination($queueName); + } + + public function declareTopic(SnsDestination $destination): void + { + $result = $this->client->createTopic([ + 'Attributes' => $destination->getAttributes(), + 'Name' => $destination->getQueueName(), + ]); + + if (false == $result->hasKey('TopicArn')) { + throw new \RuntimeException(sprintf('Cannot create topic. topicName: "%s"', $destination->getTopicName())); + } + + $this->topicArns[$destination->getTopicName()] = (string) $result->get('TopicArn'); + } + + public function setTopicArn(SnsDestination $destination, string $arn): void + { + $this->topicArns[$destination->getTopicName()] = $arn; + } + + public function deleteTopic(SnsDestination $destination): void + { + $this->client->deleteTopic($this->getTopicArn($destination)); + + unset($this->topicArns[$destination->getTopicName()]); + } + + public function subscribe(SnsSubscribe $subscribe): void + { + foreach ($this->getSubscriptions($subscribe->getTopic()) as $subscription) { + if ($subscription['Protocol'] === $subscribe->getProtocol() + && $subscription['Endpoint'] === $subscribe->getEndpoint()) { + return; + } + } + + $this->client->subscribe([ + 'Attributes' => $subscribe->getAttributes(), + 'Endpoint' => $subscribe->getEndpoint(), + 'Protocol' => $subscribe->getProtocol(), + 'ReturnSubscriptionArn' => $subscribe->isReturnSubscriptionArn(), + 'TopicArn' => $this->getTopicArn($subscribe->getTopic()), + ]); + } + + public function unsubscibe(SnsUnsubscribe $unsubscribe): void + { + foreach ($this->getSubscriptions($unsubscribe->getTopic()) as $subscription) { + if ($subscription['Protocol'] != $unsubscribe->getProtocol()) { + continue; + } + + if ($subscription['Endpoint'] != $unsubscribe->getEndpoint()) { + continue; + } + + $this->client->unsubscribe([ + 'SubscriptionArn' => $subscription['SubscriptionArn'], + ]); + } + } + + public function getSubscriptions(SnsDestination $destination): array + { + $args = [ + 'TopicArn' => $this->getTopicArn($destination), + ]; + + $subscriptions = []; + while (true) { + $result = $this->client->listSubscriptionsByTopic($args); + + $subscriptions = array_merge($subscriptions, $result->get('Subscriptions')); + + if (false == $result->hasKey('NextToken')) { + break; + } + + $args['NextToken'] = $result->get('NextToken'); + } + + return $subscriptions; + } + + public function setSubscriptionAttributes(SnsSubscribe $subscribe): void + { + foreach ($this->getSubscriptions($subscribe->getTopic()) as $subscription) { + $this->client->setSubscriptionAttributes(array_merge( + $subscribe->getAttributes(), + ['SubscriptionArn' => $subscription['SubscriptionArn']], + )); + } + } + + public function getTopicArn(SnsDestination $destination): string + { + if (false == array_key_exists($destination->getTopicName(), $this->topicArns)) { + $this->declareTopic($destination); + } + + return $this->topicArns[$destination->getTopicName()]; + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return SnsProducer + */ + public function createProducer(): Producer + { + return new SnsProducer($this); + } + + /** + * @param SnsDestination $destination + */ + public function createConsumer(Destination $destination): Consumer + { + throw new \LogicException('SNS transport does not support consumption. You should consider using SQS instead.'); + } + + public function close(): void + { + } + + /** + * @param SnsDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function getAwsSnsClient(): AwsSnsClient + { + return $this->client->getAWSClient(); + } + + public function getSnsClient(): SnsClient + { + return $this->client; + } +} diff --git a/pkg/sns/SnsDestination.php b/pkg/sns/SnsDestination.php new file mode 100644 index 000000000..adcb08f43 --- /dev/null +++ b/pkg/sns/SnsDestination.php @@ -0,0 +1,119 @@ +name = $name; + $this->attributes = []; + } + + public function getQueueName(): string + { + return $this->name; + } + + public function getTopicName(): string + { + return $this->name; + } + + /** + * The policy that defines who can access your topic. By default, only the topic owner can publish or subscribe to the topic. + */ + public function setPolicy(?string $policy = null): void + { + $this->setAttribute('Policy', $policy); + } + + public function getPolicy(): ?string + { + return $this->getAttribute('Policy'); + } + + /** + * The display name to use for a topic with SMS subscriptions. + */ + public function setDisplayName(?string $displayName = null): void + { + $this->setAttribute('DisplayName', $displayName); + } + + public function getDisplayName(): ?string + { + return $this->getAttribute('DisplayName'); + } + + /** + * The display name to use for a topic with SMS subscriptions. + */ + public function setDeliveryPolicy(?int $deliveryPolicy = null): void + { + $this->setAttribute('DeliveryPolicy', $deliveryPolicy); + } + + public function getDeliveryPolicy(): ?int + { + return $this->getAttribute('DeliveryPolicy'); + } + + /** + * Only FIFO. + * + * Designates a topic as FIFO. You can provide this attribute only during queue creation. + * You can't change it for an existing topic. When you set this attribute, you must provide aMessageGroupId + * explicitly. + * For more information, see https://docs.aws.amazon.com/sns/latest/dg/sns-fifo-topics.html + */ + public function setFifoTopic(bool $enable): void + { + $value = $enable ? 'true' : null; + + $this->setAttribute('FifoTopic', $value); + } + + /** + * Only FIFO. + * + * Enables content-based deduplication. + * For more information, see: https://docs.aws.amazon.com/sns/latest/dg/fifo-message-dedup.html + */ + public function setContentBasedDeduplication(bool $enable): void + { + $value = $enable ? 'true' : null; + + $this->setAttribute('ContentBasedDeduplication', $value); + } + + public function getAttributes(): array + { + return $this->attributes; + } + + private function getAttribute(string $name, $default = null) + { + return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + private function setAttribute(string $name, $value): void + { + if (null == $value) { + unset($this->attributes[$name]); + } else { + $this->attributes[$name] = $value; + } + } +} diff --git a/pkg/sns/SnsMessage.php b/pkg/sns/SnsMessage.php new file mode 100644 index 000000000..4122209e8 --- /dev/null +++ b/pkg/sns/SnsMessage.php @@ -0,0 +1,222 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->messageAttributes = $messageAttributes; + $this->messageStructure = $messageStructure; + $this->phoneNumber = $phoneNumber; + $this->subject = $subject; + $this->targetArn = $targetArn; + $this->redelivered = false; + } + + public function getSnsMessageId(): ?string + { + return $this->snsMessageId; + } + + public function setSnsMessageId(?string $snsMessageId): void + { + $this->snsMessageId = $snsMessageId; + } + + public function getMessageStructure(): ?string + { + return $this->messageStructure; + } + + public function setMessageStructure(?string $messageStructure): void + { + $this->messageStructure = $messageStructure; + } + + public function getPhoneNumber(): ?string + { + return $this->phoneNumber; + } + + public function setPhoneNumber(?string $phoneNumber): void + { + $this->phoneNumber = $phoneNumber; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): void + { + $this->subject = $subject; + } + + public function getMessageAttributes(): ?array + { + return $this->messageAttributes; + } + + public function setMessageAttributes(?array $messageAttributes): void + { + $this->messageAttributes = $messageAttributes; + } + + /** + * @param null $default + * + * @return array|null + */ + public function getAttribute(string $name, $default = null) + { + return array_key_exists($name, $this->messageAttributes) ? $this->messageAttributes[$name] : $default; + } + + /** + * Attribute array format: + * [ + * 'BinaryValue' => , + * 'DataType' => '', // REQUIRED + * 'StringValue' => '', + * ]. + */ + public function setAttribute(string $name, ?array $attribute): void + { + if (null === $attribute) { + unset($this->messageAttributes[$name]); + } else { + $this->messageAttributes[$name] = $attribute; + } + } + + /** + * @param string $dataType String, String.Array, Number, or Binary + * @param string|resource|StreamInterface $value + */ + public function addAttribute(string $name, string $dataType, $value): void + { + $valueKey = 'Binary' === $dataType ? 'BinaryValue' : 'StringValue'; + + $this->messageAttributes[$name] = [ + 'DataType' => $dataType, + $valueKey => $value, + ]; + } + + public function getTargetArn(): ?string + { + return $this->targetArn; + } + + public function setTargetArn(?string $targetArn): void + { + $this->targetArn = $targetArn; + } + + /** + * Only FIFO. + * + * The tag that specifies that a message belongs to a specific message group. Messages that belong to the same + * message group are processed in a FIFO manner (however, messages in different message groups might be processed + * out of order). + * To interleave multiple ordered streams within a single queue, use MessageGroupId values (for example, session + * data for multiple users). In this scenario, multiple readers can process the queue, but the session data + * of each user is processed in a FIFO fashion. + * For more information, see: https://docs.aws.amazon.com/sns/latest/dg/fifo-message-grouping.html + */ + public function setMessageGroupId(?string $id = null): void + { + $this->messageGroupId = $id; + } + + public function getMessageGroupId(): ?string + { + return $this->messageGroupId; + } + + /** + * Only FIFO. + * + * The token used for deduplication of sent messages. If a message with a particular MessageDeduplicationId is + * sent successfully, any messages sent with the same MessageDeduplicationId are accepted successfully but + * aren't delivered during the 5-minute deduplication interval. + * For more information, see https://docs.aws.amazon.com/sns/latest/dg/fifo-message-dedup.html + */ + public function setMessageDeduplicationId(?string $id = null): void + { + $this->messageDeduplicationId = $id; + } + + public function getMessageDeduplicationId(): ?string + { + return $this->messageDeduplicationId; + } +} diff --git a/pkg/sns/SnsProducer.php b/pkg/sns/SnsProducer.php new file mode 100644 index 000000000..ac7e38b5b --- /dev/null +++ b/pkg/sns/SnsProducer.php @@ -0,0 +1,153 @@ +context = $context; + } + + /** + * @param SnsDestination $destination + * @param SnsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SnsDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, SnsMessage::class); + + $body = $message->getBody(); + if (empty($body)) { + throw new InvalidMessageException('The message body must be a non-empty string.'); + } + + $topicArn = $this->context->getTopicArn($destination); + + $arguments = [ + 'Message' => $message->getBody(), + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => json_encode([$message->getHeaders(), $message->getProperties()]), + ], + ], + 'TopicArn' => $topicArn, + ]; + + if (null !== $message->getMessageAttributes()) { + $arguments['MessageAttributes'] = array_merge( + $arguments['MessageAttributes'], + $message->getMessageAttributes() + ); + } + + if (null !== ($structure = $message->getMessageStructure())) { + $arguments['MessageStructure'] = $structure; + } + if (null !== ($phone = $message->getPhoneNumber())) { + $arguments['PhoneNumber'] = $phone; + } + if (null !== ($subject = $message->getSubject())) { + $arguments['Subject'] = $subject; + } + if (null !== ($targetArn = $message->getTargetArn())) { + $arguments['TargetArn'] = $targetArn; + } + + if ($messageGroupId = $message->getMessageGroupId()) { + $arguments['MessageGroupId'] = $messageGroupId; + } + + if ($messageDeduplicationId = $message->getMessageDeduplicationId()) { + $arguments['MessageDeduplicationId'] = $messageDeduplicationId; + } + + $result = $this->context->getSnsClient()->publish($arguments); + + if (false == $result->hasKey('MessageId')) { + throw new \RuntimeException('Message was not sent'); + } + + $message->setSnsMessageId((string) $result->get('MessageId')); + } + + /** + * @throws DeliveryDelayNotSupportedException + * + * @return SnsProducer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @throws PriorityNotSupportedException + * + * @return SnsProducer + */ + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @throws TimeToLiveNotSupportedException + * + * @return SnsProducer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw TimeToLiveNotSupportedException::providerDoestNotSupportIt(); + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/sns/SnsSubscribe.php b/pkg/sns/SnsSubscribe.php new file mode 100644 index 000000000..52991d81f --- /dev/null +++ b/pkg/sns/SnsSubscribe.php @@ -0,0 +1,68 @@ +topic = $topic; + $this->endpoint = $endpoint; + $this->protocol = $protocol; + $this->returnSubscriptionArn = $returnSubscriptionArn; + $this->attributes = $attributes; + } + + public function getTopic(): SnsDestination + { + return $this->topic; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function isReturnSubscriptionArn(): bool + { + return $this->returnSubscriptionArn; + } + + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/pkg/sns/SnsUnsubscribe.php b/pkg/sns/SnsUnsubscribe.php new file mode 100644 index 000000000..ad6b93d45 --- /dev/null +++ b/pkg/sns/SnsUnsubscribe.php @@ -0,0 +1,48 @@ +topic = $topic; + $this->endpoint = $endpoint; + $this->protocol = $protocol; + } + + public function getTopic(): SnsDestination + { + return $this->topic; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getProtocol(): string + { + return $this->protocol; + } +} diff --git a/pkg/sns/Tests/SnsClientTest.php b/pkg/sns/Tests/SnsClientTest.php new file mode 100644 index 000000000..a029f4fd0 --- /dev/null +++ b/pkg/sns/Tests/SnsClientTest.php @@ -0,0 +1,227 @@ + [ + 'key' => '', + 'secret' => '', + 'region' => 'us-west-2', + 'version' => '2010-03-31', + 'endpoint' => 'http://localhost', + ]]))->createSns(); + + $client = new SnsClient($awsClient); + + $this->assertSame($awsClient, $client->getAWSClient()); + } + + public function testShouldAllowGetAwsClientIfMultipleClientProvided() + { + $awsClient = (new Sdk(['Sns' => [ + 'key' => '', + 'secret' => '', + 'region' => 'us-west-2', + 'version' => '2010-03-31', + 'endpoint' => 'http://localhost', + ]]))->createMultiRegionSns(); + + $client = new SnsClient($awsClient); + + $this->assertInstanceOf(AwsSnsClient::class, $client->getAWSClient()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testLazyApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient(function () use ($awsClient) { + return $awsClient; + }); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SnsClient(new \stdClass()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sns\SnsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidLazyInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SnsClient(function () { return new \stdClass(); }); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sns\SnsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCallWithMultiClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithSingleClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->never()) + ->method($method) + ; + + $client = new SnsClient($awsClient); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot send message to another region because transport is configured with single aws client'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithMultiClientAndEmptyCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $expectedArgs = $args; + $args['@region'] = ''; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($expectedArgs)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + public function provideApiCallsSingleClient() + { + yield [ + 'createTopic', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSnsClient::class, + ]; + + yield [ + 'publish', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSnsClient::class, + ]; + } + + public function provideApiCallsMultipleClient() + { + yield [ + 'createTopic', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'publish', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + } +} diff --git a/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php b/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php new file mode 100644 index 000000000..305a6518d --- /dev/null +++ b/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php @@ -0,0 +1,201 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string, null or instance of Aws\Sns\SnsClient'); + + new SnsConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be "sns"'); + + new SnsConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new SnsConnectionFactory('foo'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new SnsConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + 'sns:', + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + [], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + 'sns:?key=theKey&secret=theSecret&token=theToken&lazy=0', + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sns:?key=theKey&secret=theSecret&token=theToken&lazy=0'], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + ['key' => 'theKey', 'secret' => 'theSecret', 'token' => 'theToken', 'lazy' => false], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + ], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + 'topic_arns' => [], + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sns:?topic_arns[topic1]=arn:aws:sns:us-east-1:123456789012:topic1&topic_arns[topic2]=arn:aws:sns:us-west-2:123456789012:topic2'], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [ + 'topic1' => 'arn:aws:sns:us-east-1:123456789012:topic1', + 'topic2' => 'arn:aws:sns:us-west-2:123456789012:topic2', + ], + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sns:?http[timeout]=5&http[connect_timeout]=2'], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [ + 'timeout' => '5', + 'connect_timeout' => '2', + ], + ], + ]; + } +} diff --git a/pkg/sns/Tests/SnsConnectionFactoryTest.php b/pkg/sns/Tests/SnsConnectionFactoryTest.php new file mode 100644 index 000000000..4e9ad6ec9 --- /dev/null +++ b/pkg/sns/Tests/SnsConnectionFactoryTest.php @@ -0,0 +1,85 @@ +assertClassImplements(ConnectionFactory::class, SnsConnectionFactory::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $factory = new SnsConnectionFactory([]); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $factory = new SnsConnectionFactory(['key' => 'theKey']); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => 'theKey', + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'endpoint' => null, + 'topic_arns' => [], + 'http' => [], + ], 'config', $factory); + } + + public function testCouldBeConstructedWithClient() + { + $awsClient = $this->createMock(AwsSnsClient::class); + + $factory = new SnsConnectionFactory($awsClient); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SnsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SnsClient::class, $client); + $this->assertAttributeSame($awsClient, 'inputClient', $client); + } + + public function testShouldCreateLazyContext() + { + $factory = new SnsConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SnsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SnsClient::class, $client); + $this->assertAttributeInstanceOf(\Closure::class, 'inputClient', $client); + } +} diff --git a/pkg/sns/Tests/SnsDestinationTest.php b/pkg/sns/Tests/SnsDestinationTest.php new file mode 100644 index 000000000..c9f9669e7 --- /dev/null +++ b/pkg/sns/Tests/SnsDestinationTest.php @@ -0,0 +1,52 @@ +assertClassImplements(Topic::class, SnsDestination::class); + $this->assertClassImplements(Queue::class, SnsDestination::class); + } + + public function testShouldReturnNameSetInConstructor() + { + $destination = new SnsDestination('aDestinationName'); + + $this->assertSame('aDestinationName', $destination->getQueueName()); + $this->assertSame('aDestinationName', $destination->getTopicName()); + } + + public function testCouldSetPolicyAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setPolicy('thePolicy'); + + $this->assertSame(['Policy' => 'thePolicy'], $destination->getAttributes()); + } + + public function testCouldSetDisplayNameAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setDisplayName('theDisplayName'); + + $this->assertSame(['DisplayName' => 'theDisplayName'], $destination->getAttributes()); + } + + public function testCouldSetDeliveryPolicyAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setDeliveryPolicy(123); + + $this->assertSame(['DeliveryPolicy' => 123], $destination->getAttributes()); + } +} diff --git a/pkg/sns/Tests/SnsProducerTest.php b/pkg/sns/Tests/SnsProducerTest.php new file mode 100644 index 000000000..1c6be7f85 --- /dev/null +++ b/pkg/sns/Tests/SnsProducerTest.php @@ -0,0 +1,245 @@ +assertClassImplements(Producer::class, SnsProducer::class); + } + + public function testShouldThrowIfBodyOfInvalidType() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message body must be a non-empty string.'); + + $producer = new SnsProducer($this->createSnsContextMock()); + + $message = new SnsMessage(''); + + $producer->send(new SnsDestination(''), $message); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Sns\SnsDestination but got Mock_Destinat'); + + $producer = new SnsProducer($this->createSnsContextMock()); + + $producer->send($this->createMock(Destination::class), new SnsMessage()); + } + + public function testShouldThrowIfPublishFailed() + { + $destination = new SnsDestination('queue-name'); + + $client = $this->createSnsClientMock(); + $client + ->expects($this->once()) + ->method('publish') + ->willReturn(new Result()) + ; + + $context = $this->createSnsContextMock(); + $context + ->expects($this->once()) + ->method('getTopicArn') + ->with($this->identicalTo($destination)) + ->willReturn('theTopicArn') + ; + $context + ->expects($this->once()) + ->method('getSnsClient') + ->willReturn($client) + ; + + $message = new SnsMessage('foo'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Message was not sent'); + + $producer = new SnsProducer($context); + $producer->send($destination, $message); + } + + public function testShouldThrowIfsetTimeToLiveIsNotNull() + { + $this->expectException(TimeToLiveNotSupportedException::class); + + $producer = new SnsProducer($this->createSnsContextMock()); + $result = $producer->setTimeToLive(); + + $this->assertInstanceOf(SnsProducer::class, $result); + + $this->expectExceptionMessage('The provider does not support time to live feature'); + + $producer->setTimeToLive(200); + } + + public function testShouldThrowIfsetPriorityIsNotNull() + { + $this->expectException(PriorityNotSupportedException::class); + + $producer = new SnsProducer($this->createSnsContextMock()); + $result = $producer->setPriority(); + + $this->assertInstanceOf(SnsProducer::class, $result); + + $this->expectExceptionMessage('The provider does not support priority feature'); + + $producer->setPriority(200); + } + + public function testShouldThrowIfsetDeliveryDelayIsNotNull() + { + $this->expectException(DeliveryDelayNotSupportedException::class); + + $producer = new SnsProducer($this->createSnsContextMock()); + $result = $producer->setDeliveryDelay(); + + $this->assertInstanceOf(SnsProducer::class, $result); + + $this->expectExceptionMessage('The provider does not support delivery delay feature'); + + $producer->setDeliveryDelay(200); + } + + public function testShouldPublish() + { + $destination = new SnsDestination('queue-name'); + + $expectedArguments = [ + 'Message' => 'theBody', + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[{"hkey":"hvaleu"},{"key":"value"}]', + ], + ], + 'TopicArn' => 'theTopicArn', + ]; + + $client = $this->createSnsClientMock(); + $client + ->expects($this->once()) + ->method('publish') + ->with($this->identicalTo($expectedArguments)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])) + ; + + $context = $this->createSnsContextMock(); + $context + ->expects($this->once()) + ->method('getTopicArn') + ->with($this->identicalTo($destination)) + ->willReturn('theTopicArn') + ; + $context + ->expects($this->once()) + ->method('getSnsClient') + ->willReturn($client) + ; + + $message = new SnsMessage('theBody', ['key' => 'value'], ['hkey' => 'hvaleu']); + + $producer = new SnsProducer($context); + $producer->send($destination, $message); + } + + /** + * @throws InvalidMessageException + */ + public function testShouldPublishWithMergedAttributes() + { + $context = $this->createSnsContextMock(); + $client = $this->createSnsClientMock(); + + $context + ->expects($this->once()) + ->method('getSnsClient') + ->willReturn($client); + + $expectedArgument = [ + 'Message' => 'message', + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[[],[]]', + ], + 'Foo' => [ + 'DataType' => 'String', + 'StringValue' => 'foo-value', + ], + 'Bar' => [ + 'DataType' => 'Binary', + 'BinaryValue' => 'bar-val', + ], + ], + 'TopicArn' => '', + 'MessageStructure' => 'structure', + 'PhoneNumber' => 'phone', + 'Subject' => 'subject', + 'TargetArn' => 'target_arn', + ]; + + $client + ->expects($this->once()) + ->method('publish') + ->with($this->identicalTo($expectedArgument)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])); + + $attributes = [ + 'Foo' => [ + 'DataType' => 'String', + 'StringValue' => 'foo-value', + ], + ]; + + $message = new SnsMessage( + 'message', [], [], $attributes, 'structure', 'phone', + 'subject', 'target_arn' + ); + $message->addAttribute('Bar', 'Binary', 'bar-val'); + + $destination = new SnsDestination('queue-name'); + + $producer = new SnsProducer($context); + $producer->send($destination, $message); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SnsContext + */ + private function createSnsContextMock(): SnsContext + { + return $this->createMock(SnsContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SnsClient + */ + private function createSnsClientMock(): SnsClient + { + return $this->createMock(SnsClient::class); + } +} diff --git a/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php b/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php new file mode 100644 index 000000000..20008bc04 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php @@ -0,0 +1,18 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('SNS transport does not support consumption. You should consider using SQS instead.'); + + parent::testShouldCreateConsumerOnCreateConsumerMethodCall(); + } + + public function testSetsSubscriptionAttributes(): void + { + $client = $this->createMock(SnsClient::class); + $client->expects($this->once()) + ->method('listSubscriptionsByTopic') + ->willReturn(new Result(['Subscriptions' => [ + ['SubscriptionArn' => 'arn1'], + ['SubscriptionArn' => 'arn2'], + ]])); + $client->expects($this->exactly(2)) + ->method('setSubscriptionAttributes') + ->withConsecutive( + [$this->equalTo(['attr1' => 'value1', 'SubscriptionArn' => 'arn1'])], + [$this->equalTo(['attr1' => 'value1', 'SubscriptionArn' => 'arn2'])], + ); + + $context = new SnsContext($client, ['topic_arns' => ['topic1' => 'topicArn1']]); + $context->setSubscriptionAttributes(new SnsSubscribe( + new SnsDestination('topic1'), + 'endpoint1', + 'protocol1', + false, + ['attr1' => 'value1'], + )); + } + + protected function createContext() + { + $client = $this->createMock(SnsClient::class); + + return new SnsContext($client, ['topic_arns' => []]); + } +} diff --git a/pkg/sns/Tests/Spec/SnsMessageTest.php b/pkg/sns/Tests/Spec/SnsMessageTest.php new file mode 100644 index 000000000..af24344e6 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsMessageTest.php @@ -0,0 +1,14 @@ +createMock(SnsContext::class)); + } +} diff --git a/pkg/sns/Tests/Spec/SnsQueueTest.php b/pkg/sns/Tests/Spec/SnsQueueTest.php new file mode 100644 index 000000000..39c0e5513 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsQueueTest.php @@ -0,0 +1,14 @@ +createContext(); + +$queue = $context->createQueue('enqueue'); +$consumer = $context->createConsumer($queue); + +while (true) { + if ($m = $consumer->receive(20000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().\PHP_EOL; + } +} + +echo 'Done'."\n"; diff --git a/pkg/sns/examples/produce.php b/pkg/sns/examples/produce.php new file mode 100644 index 000000000..3e59c5232 --- /dev/null +++ b/pkg/sns/examples/produce.php @@ -0,0 +1,34 @@ + getenv('ENQUEUE_AWS__SQS__KEY'), + 'secret' => getenv('ENQUEUE_AWS__SQS__SECRET'), + 'region' => getenv('ENQUEUE_AWS__SQS__REGION'), +]); +$context = $factory->createContext(); + +$topic = $context->createTopic('test_enqueue'); +$context->declareTopic($topic); + +$message = $context->createMessage('a_body'); +$message->setProperty('aProp', 'aPropVal'); +$message->setHeader('aHeader', 'aHeaderVal'); + +$context->createProducer()->send($topic, $message); diff --git a/pkg/sns/phpunit.xml.dist b/pkg/sns/phpunit.xml.dist new file mode 100644 index 000000000..5f01f5897 --- /dev/null +++ b/pkg/sns/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/snsqs/.gitattributes b/pkg/snsqs/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/snsqs/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/snsqs/.github/workflows/ci.yml b/pkg/snsqs/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/snsqs/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/snsqs/.gitignore b/pkg/snsqs/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/snsqs/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/snsqs/LICENSE b/pkg/snsqs/LICENSE new file mode 100644 index 000000000..20211e5fd --- /dev/null +++ b/pkg/snsqs/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Max Kotliar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/snsqs/README.md b/pkg/snsqs/README.md new file mode 100644 index 000000000..94a22776d --- /dev/null +++ b/pkg/snsqs/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SNS-SQS Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/snsqs/ci.yml?branch=master)](https://github.com/php-enqueue/snsqs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/snsqs/d/total.png)](https://packagist.org/packages/enqueue/snsqs) +[![Latest Stable Version](https://poser.pugx.org/enqueue/snsqs/version.png)](https://packagist.org/packages/enqueue/snsqs) + +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SNS-SQS](https://aws.amazon.com/snsqs/) service. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/snsqs/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/snsqs/SnsQsConnectionFactory.php b/pkg/snsqs/SnsQsConnectionFactory.php new file mode 100644 index 000000000..65812beb3 --- /dev/null +++ b/pkg/snsqs/SnsQsConnectionFactory.php @@ -0,0 +1,114 @@ + null AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'secret' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'token' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'region' => null, (string, required) Region to connect to. See http://docs.aws.amazon.com/general/latest/gr/rande.html for a list of available regions. + * 'version' => '2012-11-05', (string, required) The version of the webservice to utilize + * 'lazy' => true, Enable lazy connection (boolean) + * 'endpoint' => null (string, default=null) The full URI of the webservice. This is only required when connecting to a custom endpoint e.g. localstack + * ]. + * + * or + * + * $config = [ + * 'sns_key' => null, SNS option + * 'sqs_secret' => null, SQS option + * 'token' Option for both SNS and SQS + * ]. + * + * or + * + * snsqs: + * snsqs:?key=aKey&secret=aSecret&sns_token=aSnsToken&sqs_token=aSqsToken + * + * @param array|string|null $config + */ + public function __construct($config = 'snsqs:') + { + if (empty($config)) { + $this->snsConfig = []; + $this->sqsConfig = []; + } elseif (is_string($config)) { + $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $this->parseDsn($config['dsn']); + } else { + $this->parseOptions($config); + } + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + } + + /** + * @return SnsQsContext + */ + public function createContext(): Context + { + return new SnsQsContext(function () { + return (new SnsConnectionFactory($this->snsConfig))->createContext(); + }, function () { + return (new SqsConnectionFactory($this->sqsConfig))->createContext(); + }); + } + + private function parseDsn(string $dsn): void + { + $dsn = Dsn::parseFirst($dsn); + + if ('snsqs' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "snsqs"', $dsn->getSchemeProtocol())); + } + + $this->parseOptions($dsn->getQuery()); + } + + private function parseOptions(array $options): void + { + // set default options + foreach ($options as $key => $value) { + if (false === in_array(substr($key, 0, 4), ['sns_', 'sqs_'], true)) { + $this->snsConfig[$key] = $value; + $this->sqsConfig[$key] = $value; + } + } + + // set transport specific options + foreach ($options as $key => $value) { + switch (substr($key, 0, 4)) { + case 'sns_': + $this->snsConfig[substr($key, 4)] = $value; + break; + case 'sqs_': + $this->sqsConfig[substr($key, 4)] = $value; + break; + } + } + } +} diff --git a/pkg/snsqs/SnsQsConsumer.php b/pkg/snsqs/SnsQsConsumer.php new file mode 100644 index 000000000..45237d145 --- /dev/null +++ b/pkg/snsqs/SnsQsConsumer.php @@ -0,0 +1,143 @@ +context = $context; + $this->consumer = $consumer; + $this->queue = $queue; + } + + public function getVisibilityTimeout(): ?int + { + return $this->consumer->getVisibilityTimeout(); + } + + /** + * The duration (in seconds) that the received messages are hidden from subsequent retrieve + * requests after being retrieved by a ReceiveMessage request. + */ + public function setVisibilityTimeout(?int $visibilityTimeout = null): void + { + $this->consumer->setVisibilityTimeout($visibilityTimeout); + } + + public function getMaxNumberOfMessages(): int + { + return $this->consumer->getMaxNumberOfMessages(); + } + + /** + * The maximum number of messages to return. Amazon SQS never returns more messages than this value + * (however, fewer messages might be returned). Valid values are 1 to 10. Default is 1. + */ + public function setMaxNumberOfMessages(int $maxNumberOfMessages): void + { + $this->consumer->setMaxNumberOfMessages($maxNumberOfMessages); + } + + public function getQueue(): Queue + { + return $this->queue; + } + + public function receive(int $timeout = 0): ?Message + { + if ($sqsMessage = $this->consumer->receive($timeout)) { + return $this->convertMessage($sqsMessage); + } + + return null; + } + + public function receiveNoWait(): ?Message + { + if ($sqsMessage = $this->consumer->receiveNoWait()) { + return $this->convertMessage($sqsMessage); + } + + return null; + } + + /** + * @param SnsQsMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + $this->consumer->acknowledge($message->getSqsMessage()); + } + + /** + * @param SnsQsMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + $this->consumer->reject($message->getSqsMessage(), $requeue); + } + + private function convertMessage(SqsMessage $sqsMessage): SnsQsMessage + { + $message = $this->context->createMessage(); + $message->setRedelivered($sqsMessage->isRedelivered()); + $message->setSqsMessage($sqsMessage); + + $body = $sqsMessage->getBody(); + + if (isset($body[0]) && '{' === $body[0]) { + $data = json_decode($sqsMessage->getBody(), true); + + if (isset($data['TopicArn']) && isset($data['Type']) && 'Notification' === $data['Type']) { + // SNS message conversion + if (isset($data['Message'])) { + $message->setBody((string) $data['Message']); + } + + if (isset($data['MessageAttributes']['Headers'])) { + $headersData = json_decode($data['MessageAttributes']['Headers']['Value'], true); + + $message->setHeaders($headersData[0]); + $message->setProperties($headersData[1]); + } + + return $message; + } + } + + $message->setBody($sqsMessage->getBody()); + $message->setHeaders($sqsMessage->getHeaders()); + $message->setProperties($sqsMessage->getProperties()); + + return $message; + } +} diff --git a/pkg/snsqs/SnsQsContext.php b/pkg/snsqs/SnsQsContext.php new file mode 100644 index 000000000..d26a0fc6d --- /dev/null +++ b/pkg/snsqs/SnsQsContext.php @@ -0,0 +1,214 @@ +snsContext = $snsContext; + } elseif (is_callable($snsContext)) { + $this->snsContextFactory = $snsContext; + } else { + throw new \InvalidArgumentException(sprintf('The $snsContext argument must be either %s or callable that returns %s once called.', SnsContext::class, SnsContext::class)); + } + + if ($sqsContext instanceof SqsContext) { + $this->sqsContext = $sqsContext; + } elseif (is_callable($sqsContext)) { + $this->sqsContextFactory = $sqsContext; + } else { + throw new \InvalidArgumentException(sprintf('The $sqsContext argument must be either %s or callable that returns %s once called.', SqsContext::class, SqsContext::class)); + } + } + + /** + * @return SnsQsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new SnsQsMessage($body, $properties, $headers); + } + + /** + * @return SnsQsTopic + */ + public function createTopic(string $topicName): Topic + { + return new SnsQsTopic($topicName); + } + + /** + * @return SnsQsQueue + */ + public function createQueue(string $queueName): Queue + { + return new SnsQsQueue($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function createProducer(): Producer + { + return new SnsQsProducer($this->getSnsContext(), $this->getSqsContext()); + } + + /** + * @param SnsQsQueue $destination + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SnsQsQueue::class); + + return new SnsQsConsumer($this, $this->getSqsContext()->createConsumer($destination), $destination); + } + + /** + * @param SnsQsQueue $queue + */ + public function purgeQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, SnsQsQueue::class); + + $this->getSqsContext()->purgeQueue($queue); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function declareTopic(SnsQsTopic $topic): void + { + $this->getSnsContext()->declareTopic($topic); + } + + public function setTopicArn(SnsQsTopic $topic, string $arn): void + { + $this->getSnsContext()->setTopicArn($topic, $arn); + } + + public function deleteTopic(SnsQsTopic $topic): void + { + $this->getSnsContext()->deleteTopic($topic); + } + + public function declareQueue(SnsQsQueue $queue): void + { + $this->getSqsContext()->declareQueue($queue); + } + + public function deleteQueue(SnsQsQueue $queue): void + { + $this->getSqsContext()->deleteQueue($queue); + } + + public function bind(SnsQsTopic $topic, SnsQsQueue $queue): void + { + $this->getSnsContext()->subscribe(new SnsSubscribe( + $topic, + $this->getSqsContext()->getQueueArn($queue), + SnsSubscribe::PROTOCOL_SQS + )); + } + + public function unbind(SnsQsTopic $topic, SnsQsQueue $queue): void + { + $this->getSnsContext()->unsubscibe(new SnsUnsubscribe( + $topic, + $this->getSqsContext()->getQueueArn($queue), + SnsSubscribe::PROTOCOL_SQS + )); + } + + public function close(): void + { + $this->getSnsContext()->close(); + $this->getSqsContext()->close(); + } + + public function setSubscriptionAttributes(SnsQsTopic $topic, SnsQsQueue $queue, array $attributes): void + { + $this->getSnsContext()->setSubscriptionAttributes(new SnsSubscribe( + $topic, + $this->getSqsContext()->getQueueArn($queue), + SnsSubscribe::PROTOCOL_SQS, + false, + $attributes, + )); + } + + private function getSnsContext(): SnsContext + { + if (null === $this->snsContext) { + $context = call_user_func($this->snsContextFactory); + if (false == $context instanceof SnsContext) { + throw new \LogicException(sprintf('The factory must return instance of %s. It returned %s', SnsContext::class, is_object($context) ? $context::class : gettype($context))); + } + + $this->snsContext = $context; + } + + return $this->snsContext; + } + + private function getSqsContext(): SqsContext + { + if (null === $this->sqsContext) { + $context = call_user_func($this->sqsContextFactory); + if (false == $context instanceof SqsContext) { + throw new \LogicException(sprintf('The factory must return instance of %s. It returned %s', SqsContext::class, is_object($context) ? $context::class : gettype($context))); + } + + $this->sqsContext = $context; + } + + return $this->sqsContext; + } +} diff --git a/pkg/snsqs/SnsQsMessage.php b/pkg/snsqs/SnsQsMessage.php new file mode 100644 index 000000000..900ad9125 --- /dev/null +++ b/pkg/snsqs/SnsQsMessage.php @@ -0,0 +1,108 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + $this->messageAttributes = $messageAttributes; + } + + public function setSqsMessage(SqsMessage $message): void + { + $this->sqsMessage = $message; + } + + public function getSqsMessage(): SqsMessage + { + return $this->sqsMessage; + } + + public function getMessageAttributes(): ?array + { + return $this->messageAttributes; + } + + public function setMessageAttributes(?array $messageAttributes): void + { + $this->messageAttributes = $messageAttributes; + } + + /** + * Only FIFO. + * + * The token used for deduplication of sent messages. If a message with a particular MessageDeduplicationId is sent successfully, + * any messages sent with the same MessageDeduplicationId are accepted successfully but aren't delivered during the 5-minute + * deduplication interval. For more information, see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html#FIFO-queues-exactly-once-processing. + */ + public function setMessageDeduplicationId(?string $id = null): void + { + $this->messageDeduplicationId = $id; + } + + public function getMessageDeduplicationId(): ?string + { + return $this->messageDeduplicationId; + } + + /** + * Only FIFO. + * + * The tag that specifies that a message belongs to a specific message group. Messages that belong to the same message group + * are processed in a FIFO manner (however, messages in different message groups might be processed out of order). + * To interleave multiple ordered streams within a single queue, use MessageGroupId values (for example, session data + * for multiple users). In this scenario, multiple readers can process the queue, but the session data + * of each user is processed in a FIFO fashion. + */ + public function setMessageGroupId(?string $id = null): void + { + $this->messageGroupId = $id; + } + + public function getMessageGroupId(): ?string + { + return $this->messageGroupId; + } +} diff --git a/pkg/snsqs/SnsQsProducer.php b/pkg/snsqs/SnsQsProducer.php new file mode 100644 index 000000000..a80e1eb2b --- /dev/null +++ b/pkg/snsqs/SnsQsProducer.php @@ -0,0 +1,143 @@ +snsContext = $snsContext; + $this->sqsContext = $sqsContext; + } + + /** + * @param SnsQsTopic $destination + * @param SnsQsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + if (false == $destination instanceof SnsQsTopic && false == $destination instanceof SnsQsQueue) { + throw new InvalidDestinationException(sprintf('The destination must be an instance of [%s|%s] but got %s.', SnsQsTopic::class, SnsQsQueue::class, is_object($destination) ? $destination::class : gettype($destination))); + } + + if ($destination instanceof SnsQsTopic) { + $snsMessage = $this->snsContext->createMessage( + $message->getBody(), + $message->getProperties(), + $message->getHeaders() + ); + $snsMessage->setMessageAttributes($message->getMessageAttributes()); + $snsMessage->setMessageGroupId($message->getMessageGroupId()); + $snsMessage->setMessageDeduplicationId($message->getMessageDeduplicationId()); + + $this->getSnsProducer()->send($destination, $snsMessage); + } else { + $sqsMessage = $this->sqsContext->createMessage( + $message->getBody(), + $message->getProperties(), + $message->getHeaders() + ); + + $sqsMessage->setMessageGroupId($message->getMessageGroupId()); + $sqsMessage->setMessageDeduplicationId($message->getMessageDeduplicationId()); + + $this->getSqsProducer()->send($destination, $sqsMessage); + } + } + + /** + * Delivery delay is supported by SQSProducer. + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->getSqsProducer()->setDeliveryDelay($deliveryDelay); + + return $this; + } + + /** + * Delivery delay is supported by SQSProducer. + */ + public function getDeliveryDelay(): ?int + { + return $this->getSqsProducer()->getDeliveryDelay(); + } + + public function setPriority(?int $priority = null): Producer + { + $this->getSnsProducer()->setPriority($priority); + $this->getSqsProducer()->setPriority($priority); + + return $this; + } + + public function getPriority(): ?int + { + return $this->getSnsProducer()->getPriority(); + } + + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->getSnsProducer()->setTimeToLive($timeToLive); + $this->getSqsProducer()->setTimeToLive($timeToLive); + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->getSnsProducer()->getTimeToLive(); + } + + private function getSnsProducer(): SnsProducer + { + if (null === $this->snsProducer) { + $this->snsProducer = $this->snsContext->createProducer(); + } + + return $this->snsProducer; + } + + private function getSqsProducer(): SqsProducer + { + if (null === $this->sqsProducer) { + $this->sqsProducer = $this->sqsContext->createProducer(); + } + + return $this->sqsProducer; + } +} diff --git a/pkg/snsqs/SnsQsQueue.php b/pkg/snsqs/SnsQsQueue.php new file mode 100644 index 000000000..92c3a542b --- /dev/null +++ b/pkg/snsqs/SnsQsQueue.php @@ -0,0 +1,11 @@ +createMock(SnsQsContext::class); + $context->expects($this->once()) + ->method('createMessage') + ->willReturn(new SnsQsMessage()); + + $sqsConsumer = $this->createMock(SqsConsumer::class); + $sqsConsumer->expects($this->once()) + ->method('receive') + ->willReturn(new SqsMessage(json_encode([ + 'Type' => 'Notification', + 'TopicArn' => 'arn:aws:sns:us-east-2:12345:topic-name', + 'Message' => 'The Body', + 'MessageAttributes' => [ + 'Headers' => [ + 'Type' => 'String', + 'Value' => '[{"headerKey":"headerVal"},{"propKey": "propVal"}]', + ], + ], + ]))); + + $consumer = new SnsQsConsumer($context, $sqsConsumer, new SnsQsQueue('queue')); + $result = $consumer->receive(); + + $this->assertInstanceOf(SnsQsMessage::class, $result); + $this->assertSame('The Body', $result->getBody()); + $this->assertSame(['headerKey' => 'headerVal'], $result->getHeaders()); + $this->assertSame(['propKey' => 'propVal'], $result->getProperties()); + } + + public function testReceivesSqsMessage(): void + { + $context = $this->createMock(SnsQsContext::class); + $context->expects($this->once()) + ->method('createMessage') + ->willReturn(new SnsQsMessage()); + + $sqsConsumer = $this->createMock(SqsConsumer::class); + $sqsConsumer->expects($this->once()) + ->method('receive') + ->willReturn(new SqsMessage( + 'The Body', + ['propKey' => 'propVal'], + ['headerKey' => 'headerVal'], + )); + + $consumer = new SnsQsConsumer($context, $sqsConsumer, new SnsQsQueue('queue')); + $result = $consumer->receive(); + + $this->assertInstanceOf(SnsQsMessage::class, $result); + $this->assertSame('The Body', $result->getBody()); + $this->assertSame(['headerKey' => 'headerVal'], $result->getHeaders()); + $this->assertSame(['propKey' => 'propVal'], $result->getProperties()); + } +} diff --git a/pkg/snsqs/Tests/SnsQsProducerTest.php b/pkg/snsqs/Tests/SnsQsProducerTest.php new file mode 100644 index 000000000..59798dc11 --- /dev/null +++ b/pkg/snsqs/Tests/SnsQsProducerTest.php @@ -0,0 +1,203 @@ +assertClassImplements(Producer::class, SnsQsProducer::class); + } + + public function testShouldThrowIfMessageIsInvalidType() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\SnsQs\SnsQsMessage but it is Double\Message\P4'); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $this->createSqsContextMock()); + + $message = $this->prophesize(Message::class)->reveal(); + + $producer->send(new SnsQsTopic(''), $message); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $this->createSqsContextMock()); + + $destination = $this->prophesize(Destination::class)->reveal(); + + $producer->send($destination, new SnsQsMessage()); + } + + public function testShouldSetDeliveryDelayToSQSProducer() + { + $delay = 10; + + $sqsProducerStub = $this->prophesize(SqsProducer::class); + $sqsProducerStub->setDeliveryDelay(Argument::is($delay))->shouldBeCalledTimes(1); + + $sqsMock = $this->createSqsContextMock(); + $sqsMock->method('createProducer')->willReturn($sqsProducerStub->reveal()); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $sqsMock); + + $producer->setDeliveryDelay($delay); + } + + public function testShouldGetDeliveryDelayFromSQSProducer() + { + $delay = 10; + + $sqsProducerStub = $this->prophesize(SqsProducer::class); + $sqsProducerStub->getDeliveryDelay()->willReturn($delay); + + $sqsMock = $this->createSqsContextMock(); + $sqsMock->method('createProducer')->willReturn($sqsProducerStub->reveal()); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $sqsMock); + + $this->assertEquals($delay, $producer->getDeliveryDelay()); + } + + public function testShouldSendSnsTopicMessageToSnsProducer() + { + $snsMock = $this->createSnsContextMock(); + $snsMock->method('createMessage')->willReturn(new SnsMessage()); + $destination = new SnsQsTopic(''); + + $snsProducerStub = $this->prophesize(SnsProducer::class); + $snsProducerStub->send($destination, Argument::any())->shouldBeCalledOnce(); + + $snsMock->method('createProducer')->willReturn($snsProducerStub->reveal()); + + $producer = new SnsQsProducer($snsMock, $this->createSqsContextMock()); + $producer->send($destination, new SnsQsMessage()); + } + + public function testShouldSendSnsTopicMessageWithAttributesToSnsProducer() + { + $snsMock = $this->createSnsContextMock(); + $snsMock->method('createMessage')->willReturn(new SnsMessage()); + $destination = new SnsQsTopic(''); + + $snsProducerStub = $this->prophesize(SnsProducer::class); + $snsProducerStub->send( + $destination, + Argument::that(function (SnsMessage $snsMessage) { + return $snsMessage->getMessageAttributes() === ['foo' => 'bar']; + }) + )->shouldBeCalledOnce(); + + $snsMock->method('createProducer')->willReturn($snsProducerStub->reveal()); + + $producer = new SnsQsProducer($snsMock, $this->createSqsContextMock()); + $producer->send($destination, new SnsQsMessage('', [], [], ['foo' => 'bar'])); + } + + public function testShouldSendToSnsTopicMessageWithGroupIdAndDeduplicationId() + { + $snsMock = $this->createSnsContextMock(); + $snsMock->method('createMessage')->willReturn(new SnsMessage()); + $destination = new SnsQsTopic(''); + + $snsProducerStub = $this->prophesize(SnsProducer::class); + $snsProducerStub->send( + $destination, + Argument::that(function (SnsMessage $snsMessage) { + return 'group-id' === $snsMessage->getMessageGroupId() + && 'deduplication-id' === $snsMessage->getMessageDeduplicationId(); + }) + )->shouldBeCalledOnce(); + + $snsMock->method('createProducer')->willReturn($snsProducerStub->reveal()); + + $snsMessage = new SnsQsMessage(); + $snsMessage->setMessageGroupId('group-id'); + $snsMessage->setMessageDeduplicationId('deduplication-id'); + + $producer = new SnsQsProducer($snsMock, $this->createSqsContextMock()); + $producer->send($destination, $snsMessage); + } + + public function testShouldSendSqsMessageToSqsProducer() + { + $sqsMock = $this->createSqsContextMock(); + $sqsMock->method('createMessage')->willReturn(new SqsMessage()); + $destination = new SnsQsQueue(''); + + $sqsProducerStub = $this->prophesize(SqsProducer::class); + $sqsProducerStub->send($destination, Argument::any())->shouldBeCalledOnce(); + + $sqsMock->method('createProducer')->willReturn($sqsProducerStub->reveal()); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $sqsMock); + $producer->send($destination, new SnsQsMessage()); + } + + public function testShouldSendToSqsProducerMessageWithGroupIdAndDeduplicationId() + { + $sqsMock = $this->createSqsContextMock(); + $sqsMock->method('createMessage')->willReturn(new SqsMessage()); + $destination = new SnsQsQueue(''); + + $sqsProducerStub = $this->prophesize(SqsProducer::class); + $sqsProducerStub->send( + $destination, + Argument::that(function (SqsMessage $sqsMessage) { + return 'group-id' === $sqsMessage->getMessageGroupId() + && 'deduplication-id' === $sqsMessage->getMessageDeduplicationId(); + }) + )->shouldBeCalledOnce(); + + $sqsMock->method('createProducer')->willReturn($sqsProducerStub->reveal()); + + $sqsMessage = new SnsQsMessage(); + $sqsMessage->setMessageGroupId('group-id'); + $sqsMessage->setMessageDeduplicationId('deduplication-id'); + + $producer = new SnsQsProducer($this->createSnsContextMock(), $sqsMock); + $producer->send($destination, $sqsMessage); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SnsContext + */ + private function createSnsContextMock(): SnsContext + { + return $this->createMock(SnsContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsContext + */ + private function createSqsContextMock(): SqsContext + { + return $this->createMock(SqsContext::class); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php b/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php new file mode 100644 index 000000000..f00c350da --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php @@ -0,0 +1,18 @@ +createMock(SnsContext::class); + $snsContext->expects($this->once()) + ->method('setSubscriptionAttributes') + ->with($this->equalTo(new SnsSubscribe( + $topic, + 'queueArn1', + 'sqs', + false, + ['attr1' => 'value1'], + ))); + + $sqsContext = $this->createMock(SqsContext::class); + $sqsContext->expects($this->any()) + ->method('createConsumer') + ->willReturn($this->createMock(SqsConsumer::class)); + $sqsContext->expects($this->any()) + ->method('getQueueArn') + ->willReturn('queueArn1'); + + $context = new SnsQsContext($snsContext, $sqsContext); + $context->setSubscriptionAttributes( + $topic, + new SnsQsQueue('queue1'), + ['attr1' => 'value1'], + ); + } + + protected function createContext() + { + $sqsContext = $this->createMock(SqsContext::class); + $sqsContext + ->expects($this->any()) + ->method('createConsumer') + ->willReturn($this->createMock(SqsConsumer::class)) + ; + + return new SnsQsContext( + $this->createMock(SnsContext::class), + $sqsContext + ); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php b/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php new file mode 100644 index 000000000..e314c2667 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php @@ -0,0 +1,68 @@ +snsQsContext = $this->buildSnsQsContext(); + } + + protected function createSnsQsQueue(string $queueName): SnsQsQueue + { + $queueName .= time(); + + $this->snsQsQueue = $this->snsQsContext->createQueue($queueName); + $this->snsQsContext->declareQueue($this->snsQsQueue); + + if ($this->snsQsTopic) { + $this->snsQsContext->bind($this->snsQsTopic, $this->snsQsQueue); + } + + return $this->snsQsQueue; + } + + protected function createSnsQsTopic(string $topicName): SnsQsTopic + { + $topicName .= time(); + + $this->snsQsTopic = $this->snsQsContext->createTopic($topicName); + $this->snsQsContext->declareTopic($this->snsQsTopic); + + return $this->snsQsTopic; + } + + protected function cleanUpSnsQs(): void + { + if ($this->snsQsTopic) { + $this->snsQsContext->deleteTopic($this->snsQsTopic); + } + + if ($this->snsQsQueue) { + $this->snsQsContext->deleteQueue($this->snsQsQueue); + } + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php b/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php new file mode 100644 index 000000000..a2815cde5 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php @@ -0,0 +1,14 @@ +createMock(SnsContext::class), + $this->createMock(SqsContext::class) + ); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php new file mode 100644 index 000000000..6a6bd4dfd --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php @@ -0,0 +1,14 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..652766de4 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,35 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php new file mode 100644 index 000000000..4a5869d63 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php @@ -0,0 +1,40 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createTopic(Context $context, $topicName) + { + return $this->createSnsQsTopic($topicName); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..433fcf3a7 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,40 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createTopic(Context $context, $topicName) + { + return $this->createSnsQsTopic($topicName); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php b/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php new file mode 100644 index 000000000..94a455987 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php @@ -0,0 +1,14 @@ + getenv('SNS_DSN'), + 'sqs' => getenv('SQS_DSN'), +]))->createContext(); + +$topic = $context->createTopic('topic'); +$queue = $context->createQueue('queue'); + +$context->declareTopic($topic); +$context->declareQueue($queue); +$context->bind($topic, $queue); + +$consumer = $context->createConsumer($queue); + +while (true) { + if ($m = $consumer->receive(20000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().' '.json_encode($m->getHeaders()).' '.json_encode($m->getProperties()).\PHP_EOL; + } +} +echo 'Done'."\n"; diff --git a/pkg/snsqs/examples/produce.php b/pkg/snsqs/examples/produce.php new file mode 100644 index 000000000..53018d769 --- /dev/null +++ b/pkg/snsqs/examples/produce.php @@ -0,0 +1,40 @@ + getenv('SNS_DSN'), + 'sqs' => getenv('SQS_DSN'), +]))->createContext(); + +$topic = $context->createTopic('topic'); +$queue = $context->createQueue('queue'); + +$context->declareTopic($topic); +$context->declareQueue($queue); +$context->bind($topic, $queue); + +$message = $context->createMessage('Hello Bar!', ['key' => 'value'], ['key2' => 'value2']); + +while (true) { + $context->createProducer()->send($topic, $message); + echo 'Sent message: '.$message->getBody().\PHP_EOL; + sleep(1); +} + +echo 'Done'."\n"; diff --git a/pkg/snsqs/phpunit.xml.dist b/pkg/snsqs/phpunit.xml.dist new file mode 100644 index 000000000..9adb0b184 --- /dev/null +++ b/pkg/snsqs/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/sqs/.gitattributes b/pkg/sqs/.gitattributes new file mode 100644 index 000000000..3fab2dac1 --- /dev/null +++ b/pkg/sqs/.gitattributes @@ -0,0 +1,6 @@ +/examples export-ignore +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/sqs/.github/workflows/ci.yml b/pkg/sqs/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/sqs/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/sqs/.gitignore b/pkg/sqs/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/sqs/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/sqs/LICENSE b/pkg/sqs/LICENSE new file mode 100644 index 000000000..f1e6a22fe --- /dev/null +++ b/pkg/sqs/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2016 Kotliar Maksym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/sqs/README.md b/pkg/sqs/README.md new file mode 100644 index 000000000..7f4170bf2 --- /dev/null +++ b/pkg/sqs/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SQS Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/sqs/ci.yml?branch=master)](https://github.com/php-enqueue/sqs/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/sqs/d/total.png)](https://packagist.org/packages/enqueue/sqs) +[![Latest Stable Version](https://poser.pugx.org/enqueue/sqs/version.png)](https://packagist.org/packages/enqueue/sqs) + +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SQS](https://aws.amazon.com/sqs/) service. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/sqs/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/sqs/SqsClient.php b/pkg/sqs/SqsClient.php new file mode 100644 index 000000000..bba2a5760 --- /dev/null +++ b/pkg/sqs/SqsClient.php @@ -0,0 +1,153 @@ +inputClient = $inputClient; + } + + public function deleteMessage(array $args): Result + { + return $this->callApi('deleteMessage', $args); + } + + public function receiveMessage(array $args): Result + { + return $this->callApi('receiveMessage', $args); + } + + public function changeMessageVisibility(array $args): Result + { + return $this->callApi('changeMessageVisibility', $args); + } + + public function purgeQueue(array $args): Result + { + return $this->callApi('purgeQueue', $args); + } + + public function getQueueUrl(array $args): Result + { + return $this->callApi('getQueueUrl', $args); + } + + public function getQueueAttributes(array $args): Result + { + return $this->callApi('getQueueAttributes', $args); + } + + public function createQueue(array $args): Result + { + return $this->callApi('createQueue', $args); + } + + public function deleteQueue(array $args): Result + { + return $this->callApi('deleteQueue', $args); + } + + public function sendMessage(array $args): Result + { + return $this->callApi('sendMessage', $args); + } + + public function getAWSClient(): AwsSqsClient + { + $this->resolveClient(); + + if ($this->singleClient) { + return $this->singleClient; + } + + if ($this->multiClient) { + $mr = new \ReflectionMethod($this->multiClient, 'getClientFromPool'); + $mr->setAccessible(true); + $singleClient = $mr->invoke($this->multiClient, $this->multiClient->getRegion()); + $mr->setAccessible(false); + + return $singleClient; + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function callApi(string $name, array $args): Result + { + $this->resolveClient(); + + if ($this->singleClient) { + if (false == empty($args['@region'])) { + throw new \LogicException('Cannot send message to another region because transport is configured with single aws client'); + } + + unset($args['@region']); + + return call_user_func([$this->singleClient, $name], $args); + } + + if ($this->multiClient) { + return call_user_func([$this->multiClient, $name], $args); + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function resolveClient(): void + { + if ($this->singleClient || $this->multiClient) { + return; + } + + $client = $this->inputClient; + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } elseif ($client instanceof AwsSqsClient) { + $this->singleClient = $client; + + return; + } elseif (is_callable($client)) { + $client = call_user_func($client); + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } + if ($client instanceof AwsSqsClient) { + $this->singleClient = $client; + + return; + } + } + + throw new \LogicException(sprintf('The input client must be an instance of "%s" or "%s" or a callable that returns one of those. Got "%s"', AwsSqsClient::class, MultiRegionClient::class, is_object($client) ? $client::class : gettype($client))); + } +} diff --git a/pkg/sqs/SqsConnectionFactory.php b/pkg/sqs/SqsConnectionFactory.php new file mode 100644 index 000000000..71e73b705 --- /dev/null +++ b/pkg/sqs/SqsConnectionFactory.php @@ -0,0 +1,166 @@ + null AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'secret' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'token' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'region' => null, (string, required) Region to connect to. See http://docs.aws.amazon.com/general/latest/gr/rande.html for a list of available regions. + * 'retries' => 3, (int, default=int(3)) Configures the maximum number of allowed retries for a client (pass 0 to disable retries). + * 'version' => '2012-11-05', (string, required) The version of the webservice to utilize + * 'lazy' => true, Enable lazy connection (boolean) + * 'endpoint' => null, (string, default=null) The full URI of the webservice. This is only required when connecting to a custom endpoint e.g. localstack + * 'profile' => null, (string, default=null) The name of an AWS profile to used, if provided the SDK will attempt to read associated credentials from the ~/.aws/credentials file. + * 'queue_owner_aws_account_id' The AWS account ID of the account that created the queue. + * ]. + * + * or + * + * sqs: + * sqs::?key=aKey&secret=aSecret&token=aToken + * + * @param array|string|AwsSqsClient|null $config + */ + public function __construct($config = 'sqs:') + { + if ($config instanceof AwsSqsClient) { + $this->client = new SqsClient($config); + $this->config = ['lazy' => false] + $this->defaultConfig(); + + return; + } + + if (empty($config)) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace_recursive($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSqsClient::class)); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return SqsContext + */ + public function createContext(): Context + { + return new SqsContext($this->establishConnection(), $this->config); + } + + private function establishConnection(): SqsClient + { + if ($this->client) { + return $this->client; + } + + $config = [ + 'version' => $this->config['version'], + 'retries' => $this->config['retries'], + 'region' => $this->config['region'], + ]; + + if (isset($this->config['endpoint'])) { + $config['endpoint'] = $this->config['endpoint']; + } + + if (isset($this->config['profile'])) { + $config['profile'] = $this->config['profile']; + } + + if ($this->config['key'] && $this->config['secret']) { + $config['credentials'] = [ + 'key' => $this->config['key'], + 'secret' => $this->config['secret'], + ]; + + if ($this->config['token']) { + $config['credentials']['token'] = $this->config['token']; + } + } + + if (isset($this->config['http'])) { + $config['http'] = $this->config['http']; + } + + $establishConnection = function () use ($config) { + return (new Sdk(['Sqs' => $config]))->createMultiRegionSqs(); + }; + + $this->client = $this->config['lazy'] ? + new SqsClient($establishConnection) : + new SqsClient($establishConnection()) + ; + + return $this->client; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('sqs' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "sqs"', $dsn->getSchemeProtocol())); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'key' => $dsn->getString('key'), + 'secret' => $dsn->getString('secret'), + 'token' => $dsn->getString('token'), + 'region' => $dsn->getString('region'), + 'retries' => $dsn->getDecimal('retries'), + 'version' => $dsn->getString('version'), + 'lazy' => $dsn->getBool('lazy'), + 'endpoint' => $dsn->getString('endpoint'), + 'profile' => $dsn->getString('profile'), + 'queue_owner_aws_account_id' => $dsn->getString('queue_owner_aws_account_id'), + 'http' => $dsn->getArray('http', [])->toArray(), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ]; + } +} diff --git a/pkg/sqs/SqsConsumer.php b/pkg/sqs/SqsConsumer.php new file mode 100644 index 000000000..860bc648b --- /dev/null +++ b/pkg/sqs/SqsConsumer.php @@ -0,0 +1,210 @@ +context = $context; + $this->queue = $queue; + $this->messages = []; + $this->maxNumberOfMessages = 1; + } + + public function getVisibilityTimeout(): ?int + { + return $this->visibilityTimeout; + } + + /** + * The duration (in seconds) that the received messages are hidden from subsequent retrieve + * requests after being retrieved by a ReceiveMessage request. + */ + public function setVisibilityTimeout(?int $visibilityTimeout = null): void + { + $this->visibilityTimeout = $visibilityTimeout; + } + + public function getMaxNumberOfMessages(): int + { + return $this->maxNumberOfMessages; + } + + /** + * The maximum number of messages to return. Amazon SQS never returns more messages than this value + * (however, fewer messages might be returned). Valid values are 1 to 10. Default is 1. + */ + public function setMaxNumberOfMessages(int $maxNumberOfMessages): void + { + $this->maxNumberOfMessages = $maxNumberOfMessages; + } + + /** + * @return SqsDestination + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * @return SqsMessage + */ + public function receive(int $timeout = 0): ?Message + { + $maxLongPollingTime = 20; // 20 is max allowed long polling value + + if (0 === $timeout) { + while (true) { + if ($message = $this->receiveMessage($maxLongPollingTime)) { + return $message; + } + } + } + + $timeout = (int) ceil($timeout / 1000); + + if ($timeout > $maxLongPollingTime) { + throw new \LogicException(sprintf('Max allowed SQS receive message timeout is: "%s"', $maxLongPollingTime)); + } + + return $this->receiveMessage($timeout); + } + + /** + * @return SqsMessage + */ + public function receiveNoWait(): ?Message + { + return $this->receiveMessage(0); + } + + /** + * @param SqsMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, SqsMessage::class); + + $this->context->getSqsClient()->deleteMessage([ + '@region' => $this->queue->getRegion(), + 'QueueUrl' => $this->context->getQueueUrl($this->queue), + 'ReceiptHandle' => $message->getReceiptHandle(), + ]); + } + + /** + * @param SqsMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, SqsMessage::class); + + if ($requeue) { + $this->context->getSqsClient()->changeMessageVisibility([ + '@region' => $this->queue->getRegion(), + 'QueueUrl' => $this->context->getQueueUrl($this->queue), + 'ReceiptHandle' => $message->getReceiptHandle(), + 'VisibilityTimeout' => $message->getRequeueVisibilityTimeout(), + ]); + } else { + $this->context->getSqsClient()->deleteMessage([ + '@region' => $this->queue->getRegion(), + 'QueueUrl' => $this->context->getQueueUrl($this->queue), + 'ReceiptHandle' => $message->getReceiptHandle(), + ]); + } + } + + protected function receiveMessage(int $timeoutSeconds): ?SqsMessage + { + if ($message = array_pop($this->messages)) { + return $this->convertMessage($message); + } + + $arguments = [ + '@region' => $this->queue->getRegion(), + 'AttributeNames' => ['All'], + 'MessageAttributeNames' => ['All'], + 'MaxNumberOfMessages' => $this->maxNumberOfMessages, + 'QueueUrl' => $this->context->getQueueUrl($this->queue), + 'WaitTimeSeconds' => $timeoutSeconds, + ]; + + if ($this->visibilityTimeout) { + $arguments['VisibilityTimeout'] = $this->visibilityTimeout; + } + + $result = $this->context->getSqsClient()->receiveMessage($arguments); + + if ($result->hasKey('Messages')) { + $this->messages = $result->get('Messages'); + } + + if ($message = array_pop($this->messages)) { + return $this->convertMessage($message); + } + + return null; + } + + protected function convertMessage(array $sqsMessage): SqsMessage + { + $message = $this->context->createMessage(); + + $message->setBody($sqsMessage['Body']); + $message->setReceiptHandle($sqsMessage['ReceiptHandle']); + + if (isset($sqsMessage['Attributes'])) { + $message->setAttributes($sqsMessage['Attributes']); + } + + if (isset($sqsMessage['Attributes']['ApproximateReceiveCount'])) { + $message->setRedelivered(((int) $sqsMessage['Attributes']['ApproximateReceiveCount']) > 1); + } + + if (isset($sqsMessage['MessageAttributes']['Headers'])) { + $headers = json_decode($sqsMessage['MessageAttributes']['Headers']['StringValue'], true); + + $message->setHeaders($headers[0]); + $message->setProperties($headers[1]); + } + + $message->setMessageId($sqsMessage['MessageId']); + + return $message; + } +} diff --git a/pkg/sqs/SqsContext.php b/pkg/sqs/SqsContext.php new file mode 100644 index 000000000..65f12ae89 --- /dev/null +++ b/pkg/sqs/SqsContext.php @@ -0,0 +1,212 @@ +client = $client; + $this->config = $config; + + $this->queueUrls = []; + $this->queueArns = []; + } + + /** + * @return SqsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new SqsMessage($body, $properties, $headers); + } + + /** + * @return SqsDestination + */ + public function createTopic(string $topicName): Topic + { + return new SqsDestination($topicName); + } + + /** + * @return SqsDestination + */ + public function createQueue(string $queueName): Queue + { + return new SqsDestination($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return SqsProducer + */ + public function createProducer(): Producer + { + return new SqsProducer($this); + } + + /** + * @param SqsDestination $destination + * + * @return SqsConsumer + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SqsDestination::class); + + return new SqsConsumer($this, $destination); + } + + public function close(): void + { + } + + /** + * @param SqsDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, SqsDestination::class); + + $this->client->purgeQueue([ + '@region' => $queue->getRegion(), + 'QueueUrl' => $this->getQueueUrl($queue), + ]); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function getAwsSqsClient(): AwsSqsClient + { + return $this->client->getAWSClient(); + } + + public function getSqsClient(): SqsClient + { + return $this->client; + } + + /** + * @deprecated use getAwsSqsClient method + */ + public function getClient(): AwsSqsClient + { + @trigger_error('The method is deprecated since 0.9.2. SqsContext::getAwsSqsClient() method should be used.', \E_USER_DEPRECATED); + + return $this->getAwsSqsClient(); + } + + public function getQueueUrl(SqsDestination $destination): string + { + if (isset($this->queueUrls[$destination->getQueueName()])) { + return $this->queueUrls[$destination->getQueueName()]; + } + + $arguments = [ + '@region' => $destination->getRegion(), + 'QueueName' => $destination->getQueueName(), + ]; + + if ($destination->getQueueOwnerAWSAccountId()) { + $arguments['QueueOwnerAWSAccountId'] = $destination->getQueueOwnerAWSAccountId(); + } elseif (false == empty($this->config['queue_owner_aws_account_id'])) { + $arguments['QueueOwnerAWSAccountId'] = $this->config['queue_owner_aws_account_id']; + } + + $result = $this->client->getQueueUrl($arguments); + + if (false == $result->hasKey('QueueUrl')) { + throw new \RuntimeException(sprintf('QueueUrl cannot be resolved. queueName: "%s"', $destination->getQueueName())); + } + + return $this->queueUrls[$destination->getQueueName()] = (string) $result->get('QueueUrl'); + } + + public function getQueueArn(SqsDestination $destination): string + { + if (isset($this->queueArns[$destination->getQueueName()])) { + return $this->queueArns[$destination->getQueueName()]; + } + + $arguments = [ + '@region' => $destination->getRegion(), + 'QueueUrl' => $this->getQueueUrl($destination), + 'AttributeNames' => ['QueueArn'], + ]; + + $result = $this->client->getQueueAttributes($arguments); + + if (false == $arn = $result->search('Attributes.QueueArn')) { + throw new \RuntimeException(sprintf('QueueArn cannot be resolved. queueName: "%s"', $destination->getQueueName())); + } + + return $this->queueArns[$destination->getQueueName()] = (string) $arn; + } + + public function declareQueue(SqsDestination $dest): void + { + $result = $this->client->createQueue([ + '@region' => $dest->getRegion(), + 'Attributes' => $dest->getAttributes(), + 'QueueName' => $dest->getQueueName(), + ]); + + if (false == $result->hasKey('QueueUrl')) { + throw new \RuntimeException(sprintf('Cannot create queue. queueName: "%s"', $dest->getQueueName())); + } + + $this->queueUrls[$dest->getQueueName()] = $result->get('QueueUrl'); + } + + public function deleteQueue(SqsDestination $dest): void + { + $this->client->deleteQueue([ + 'QueueUrl' => $this->getQueueUrl($dest), + ]); + + unset($this->queueUrls[$dest->getQueueName()]); + } +} diff --git a/pkg/sqs/SqsDestination.php b/pkg/sqs/SqsDestination.php new file mode 100644 index 000000000..d77966f15 --- /dev/null +++ b/pkg/sqs/SqsDestination.php @@ -0,0 +1,220 @@ +name = $name; + $this->attributes = []; + } + + public function getQueueName(): string + { + return $this->name; + } + + public function getTopicName(): string + { + return $this->name; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * The number of seconds for which the delivery of all messages in the queue is delayed. + * Valid values: An integer from 0 to 900 seconds (15 minutes). The default is 0 (zero). + */ + public function setDelaySeconds(?int $seconds = null): void + { + if (null == $seconds) { + unset($this->attributes['DelaySeconds']); + } else { + $this->attributes['DelaySeconds'] = $seconds; + } + } + + /** + * The limit of how many bytes a message can contain before Amazon SQS rejects it. + * Valid values: An integer from 1,024 bytes (1 KiB) to 262,144 bytes (256 KiB). + * The default is 262,144 (256 KiB). + */ + public function setMaximumMessageSize(?int $bytes = null): void + { + if (null == $bytes) { + unset($this->attributes['MaximumMessageSize']); + } else { + $this->attributes['MaximumMessageSize'] = $bytes; + } + } + + /** + * The number of seconds for which Amazon SQS retains a message. + * Valid values: An integer from 60 seconds (1 minute) to 1,209,600 seconds (14 days). + * The default is 345,600 (4 days). + */ + public function setMessageRetentionPeriod(?int $seconds = null): void + { + if (null == $seconds) { + unset($this->attributes['MessageRetentionPeriod']); + } else { + $this->attributes['MessageRetentionPeriod'] = $seconds; + } + } + + /** + * The queue's policy. A valid AWS policy. For more information about policy structure, + * see http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html. + */ + public function setPolicy(?string $policy = null): void + { + if (null == $policy) { + unset($this->attributes['Policy']); + } else { + $this->attributes['Policy'] = $policy; + } + } + + /** + * The number of seconds for which a ReceiveMessage action waits for a message to arrive. + * Valid values: An integer from 0 to 20 (seconds). The default is 0 (zero). + */ + public function setReceiveMessageWaitTimeSeconds(?int $seconds = null): void + { + if (null == $seconds) { + unset($this->attributes['ReceiveMessageWaitTimeSeconds']); + } else { + $this->attributes['ReceiveMessageWaitTimeSeconds'] = $seconds; + } + } + + /** + * The parameters for the dead letter queue functionality of the source queue. + * For more information about the redrive policy and dead letter queues, + * see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html. + * The dead letter queue of a FIFO queue must also be a FIFO queue. + * Similarly, the dead letter queue of a standard queue must also be a standard queue. + */ + public function setRedrivePolicy(int $maxReceiveCount, string $deadLetterTargetArn): void + { + $this->attributes['RedrivePolicy'] = json_encode([ + 'maxReceiveCount' => (string) $maxReceiveCount, + 'deadLetterTargetArn' => (string) $deadLetterTargetArn, + ]); + } + + /** + * The visibility timeout for the queue. Valid values: An integer from 0 to 43,200 (12 hours). + * The default is 30. For more information about the visibility timeout, + * see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html. + */ + public function setVisibilityTimeout(?int $seconds = null): void + { + if (null == $seconds) { + unset($this->attributes['VisibilityTimeout']); + } else { + $this->attributes['VisibilityTimeout'] = $seconds; + } + } + + /** + * Only FIFO. + * + * Designates a queue as FIFO. You can provide this attribute only during queue creation. + * You can't change it for an existing queue. When you set this attribute, you must provide a MessageGroupId explicitly. + * For more information, see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html#FIFO-queues-understanding-logic. + */ + public function setFifoQueue(bool $enable): void + { + if ($enable) { + $this->attributes['FifoQueue'] = 'true'; + } else { + unset($this->attributes['FifoQueue']); + } + } + + /** + * Only FIFO. + * + * Enables content-based deduplication. + * For more information, see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html#FIFO-queues-exactly-once-processing. + * * Every message must have a unique MessageDeduplicationId, + * * You may provide a MessageDeduplicationId explicitly. + * * If you aren't able to provide a MessageDeduplicationId and you enable ContentBasedDeduplication for your queue, + * Amazon SQS uses a SHA-256 hash to generate the MessageDeduplicationId using the body of the message (but not the attributes of the message). + * * If you don't provide a MessageDeduplicationId and the queue doesn't have ContentBasedDeduplication set, + * the action fails with an error. + * * If the queue has ContentBasedDeduplication set, your MessageDeduplicationId overrides the generated one. + * * When ContentBasedDeduplication is in effect, messages with identical content sent within the deduplication + * interval are treated as duplicates and only one copy of the message is delivered. + * * You can also use ContentBasedDeduplication for messages with identical content to be treated as duplicates. + * * If you send one message with ContentBasedDeduplication enabled and then another message with a MessageDeduplicationId + * that is the same as the one generated for the first MessageDeduplicationId, the two messages are treated as + * duplicates and only one copy of the message is delivered. + */ + public function setContentBasedDeduplication(bool $enable): void + { + if ($enable) { + $this->attributes['ContentBasedDeduplication'] = 'true'; + } else { + unset($this->attributes['ContentBasedDeduplication']); + } + } + + public function getQueueOwnerAWSAccountId(): ?string + { + return $this->queueOwnerAWSAccountId; + } + + public function setQueueOwnerAWSAccountId(?string $queueOwnerAWSAccountId): void + { + $this->queueOwnerAWSAccountId = $queueOwnerAWSAccountId; + } + + public function setRegion(?string $region = null): void + { + $this->region = $region; + } + + public function getRegion(): ?string + { + return $this->region; + } +} diff --git a/pkg/sqs/SqsMessage.php b/pkg/sqs/SqsMessage.php new file mode 100644 index 000000000..772c3e217 --- /dev/null +++ b/pkg/sqs/SqsMessage.php @@ -0,0 +1,274 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->attributes = []; + $this->redelivered = false; + $this->delaySeconds = 0; + $this->requeueVisibilityTimeout = 0; + } + + public function setBody(string $body): void + { + $this->body = $body; + } + + public function getBody(): string + { + return $this->body; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function setProperty(string $name, $value): void + { + $this->properties[$name] = $value; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function getProperty(string $name, $default = null) + { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; + } + + public function setHeader(string $name, $value): void + { + $this->headers[$name] = $value; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name, $default = null) + { + return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; + } + + public function setAttributes(array $attributes): void + { + $this->attributes = $attributes; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute(string $name, $default = null) + { + return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + public function isRedelivered(): bool + { + return $this->redelivered; + } + + public function setRedelivered(bool $redelivered): void + { + $this->redelivered = $redelivered; + } + + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } + + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + public function getTimestamp(): ?int + { + $value = $this->getHeader('timestamp'); + + return null === $value ? null : (int) $value; + } + + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + /** + * The number of seconds to delay a specific message. Valid values: 0 to 900. Maximum: 15 minutes. + * Messages with a positive DelaySeconds value become available for processing after the delay period is finished. + * If you don't specify a value, the default value for the queue applies. + * When you set FifoQueue, you can't set DelaySeconds per message. You can set this parameter only on a queue level. + * + * Set delay in seconds + */ + public function setDelaySeconds(int $seconds): void + { + $this->delaySeconds = $seconds; + } + + public function getDelaySeconds(): int + { + return $this->delaySeconds; + } + + /** + * Only FIFO. + * + * The token used for deduplication of sent messages. If a message with a particular MessageDeduplicationId is sent successfully, + * any messages sent with the same MessageDeduplicationId are accepted successfully but aren't delivered during the 5-minute + * deduplication interval. For more information, see http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html#FIFO-queues-exactly-once-processing. + */ + public function setMessageDeduplicationId(?string $id = null): void + { + $this->messageDeduplicationId = $id; + } + + public function getMessageDeduplicationId(): ?string + { + return $this->messageDeduplicationId; + } + + /** + * Only FIFO. + * + * The tag that specifies that a message belongs to a specific message group. Messages that belong to the same message group + * are processed in a FIFO manner (however, messages in different message groups might be processed out of order). + * To interleave multiple ordered streams within a single queue, use MessageGroupId values (for example, session data + * for multiple users). In this scenario, multiple readers can process the queue, but the session data + * of each user is processed in a FIFO fashion. + */ + public function setMessageGroupId(?string $id = null): void + { + $this->messageGroupId = $id; + } + + public function getMessageGroupId(): ?string + { + return $this->messageGroupId; + } + + /** + * This handle is associated with the action of receiving the message, not with the message itself. + * To delete the message or to change the message visibility, you must provide the receipt handle (not the message ID). + * + * If you receive a message more than once, each time you receive it, you get a different receipt handle. + * You must provide the most recently received receipt handle when you request to delete the message (otherwise, the message might not be deleted). + */ + public function setReceiptHandle(?string $receipt = null): void + { + $this->receiptHandle = $receipt; + } + + public function getReceiptHandle(): ?string + { + return $this->receiptHandle; + } + + /** + * The number of seconds before the message can be visible again when requeuing. Valid values: 0 to 43200. Maximum: 12 hours. + * + * Set requeue visibility timeout + */ + public function setRequeueVisibilityTimeout(int $seconds): void + { + $this->requeueVisibilityTimeout = $seconds; + } + + public function getRequeueVisibilityTimeout(): int + { + return $this->requeueVisibilityTimeout; + } +} diff --git a/pkg/sqs/SqsProducer.php b/pkg/sqs/SqsProducer.php new file mode 100644 index 000000000..2e43d8370 --- /dev/null +++ b/pkg/sqs/SqsProducer.php @@ -0,0 +1,129 @@ +context = $context; + } + + /** + * @param SqsDestination $destination + * @param SqsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SqsDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, SqsMessage::class); + + $body = $message->getBody(); + if (empty($body)) { + throw new InvalidMessageException('The message body must be a non-empty string.'); + } + + $arguments = [ + '@region' => $destination->getRegion(), + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => json_encode([$message->getHeaders(), $message->getProperties()]), + ], + ], + 'MessageBody' => $body, + 'QueueUrl' => $this->context->getQueueUrl($destination), + ]; + + if (null !== $this->deliveryDelay) { + $arguments['DelaySeconds'] = (int) ceil($this->deliveryDelay / 1000); + } + + if ($message->getDelaySeconds()) { + $arguments['DelaySeconds'] = $message->getDelaySeconds(); + } + + if ($message->getMessageDeduplicationId()) { + $arguments['MessageDeduplicationId'] = $message->getMessageDeduplicationId(); + } + + if ($message->getMessageGroupId()) { + $arguments['MessageGroupId'] = $message->getMessageGroupId(); + } + + $result = $this->context->getSqsClient()->sendMessage($arguments); + + if (false == $result->hasKey('MessageId')) { + throw new \RuntimeException('Message was not sent'); + } + } + + /** + * @return SqsProducer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return SqsProducer + */ + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @return SqsProducer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw TimeToLiveNotSupportedException::providerDoestNotSupportIt(); + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/sqs/Tests/Functional/SqsCommonUseCasesTest.php b/pkg/sqs/Tests/Functional/SqsCommonUseCasesTest.php new file mode 100644 index 000000000..7d06df229 --- /dev/null +++ b/pkg/sqs/Tests/Functional/SqsCommonUseCasesTest.php @@ -0,0 +1,126 @@ +context = $this->buildSqsContext(); + + $this->queueName = str_replace('.', '_dot_', uniqid('enqueue_test_queue_', true)); + $this->queue = $this->context->createQueue($this->queueName); + + $this->context->declareQueue($this->queue); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + public function testWaitsForTwoSecondsAndReturnNullOnReceive() + { + $queue = $this->context->createQueue($this->queueName); + + $startAt = microtime(true); + + $consumer = $this->context->createConsumer($queue); + $message = $consumer->receive(2000); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertGreaterThan(1.5, $endAt - $startAt); + $this->assertLessThan(2.5, $endAt - $startAt); + } + + public function testReturnNullImmediatelyOnReceiveNoWait() + { + $queue = $this->context->createQueue($this->queueName); + + $startAt = microtime(true); + + $consumer = $this->context->createConsumer($queue); + $message = $consumer->receiveNoWait(); + + $endAt = microtime(true); + + $this->assertNull($message); + + $this->assertLessThan(2, $endAt - $startAt); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToQueue() + { + $queue = $this->context->createQueue($this->queueName); + + $message = $this->context->createMessage( + __METHOD__, + ['FooProperty' => 'FooVal'], + ['BarHeader' => 'BarVal'] + ); + + $producer = $this->context->createProducer(); + $producer->send($queue, $message); + + $consumer = $this->context->createConsumer($queue); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(SqsMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + $this->assertEquals(['FooProperty' => 'FooVal'], $message->getProperties()); + $this->assertEquals('BarVal', $message->getHeaders()['BarHeader']); + $this->assertNotNull($message->getMessageId()); + } + + public function testProduceAndReceiveOneMessageSentDirectlyToTopic() + { + $topic = $this->context->createTopic($this->queueName); + + $message = $this->context->createMessage(__METHOD__); + + $producer = $this->context->createProducer(); + $producer->send($topic, $message); + + $consumer = $this->context->createConsumer($topic); + $message = $consumer->receive(1000); + + $this->assertInstanceOf(SqsMessage::class, $message); + $consumer->acknowledge($message); + + $this->assertEquals(__METHOD__, $message->getBody()); + } +} diff --git a/pkg/sqs/Tests/Functional/SqsConsumptionUseCasesTest.php b/pkg/sqs/Tests/Functional/SqsConsumptionUseCasesTest.php new file mode 100644 index 000000000..9c57dcbdc --- /dev/null +++ b/pkg/sqs/Tests/Functional/SqsConsumptionUseCasesTest.php @@ -0,0 +1,122 @@ +context = $this->buildSqsContext(); + + $queue = $this->context->createQueue('enqueue_test_queue'); + $replyQueue = $this->context->createQueue('enqueue_test_queue_reply'); + + $this->context->declareQueue($queue); + $this->context->declareQueue($replyQueue); + + try { + $this->context->purgeQueue($queue); + $this->context->purgeQueue($replyQueue); + } catch (\Exception $e) { + } + } + + /** + * @retry 5 + */ + public function testConsumeOneMessageAndExit() + { + $queue = $this->context->createQueue('enqueue_test_queue'); + + $message = $this->context->createMessage(__METHOD__); + $this->context->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->context, new ChainExtension([ + new LimitConsumedMessagesExtension(1), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + ])); + + $processor = new StubProcessor(); + $queueConsumer->bind($queue, $processor); + + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + } + + /** + * @retry 5 + */ + public function testConsumeOneMessageAndSendReplyExit() + { + $queue = $this->context->createQueue('enqueue_test_queue'); + $replyQueue = $this->context->createQueue('enqueue_test_queue_reply'); + + $message = $this->context->createMessage(__METHOD__); + $message->setReplyTo($replyQueue->getQueueName()); + $this->context->createProducer()->send($queue, $message); + + $queueConsumer = new QueueConsumer($this->context, new ChainExtension([ + new LimitConsumedMessagesExtension(2), + new LimitConsumptionTimeExtension(new \DateTime('+3sec')), + new ReplyExtension(), + ])); + + $replyMessage = $this->context->createMessage(__METHOD__.'.reply'); + + $processor = new StubProcessor(); + $processor->result = Result::reply($replyMessage); + + $replyProcessor = new StubProcessor(); + + $queueConsumer->bind($queue, $processor); + $queueConsumer->bind($replyQueue, $replyProcessor); + $queueConsumer->consume(); + + $this->assertInstanceOf(Message::class, $processor->lastProcessedMessage); + $this->assertEquals(__METHOD__, $processor->lastProcessedMessage->getBody()); + + $this->assertInstanceOf(Message::class, $replyProcessor->lastProcessedMessage); + $this->assertEquals(__METHOD__.'.reply', $replyProcessor->lastProcessedMessage->getBody()); + } +} + +class StubProcessor implements Processor +{ + public $result = self::ACK; + + /** @var Message */ + public $lastProcessedMessage; + + public function process(Message $message, Context $context) + { + $this->lastProcessedMessage = $message; + + return $this->result; + } +} diff --git a/pkg/sqs/Tests/Spec/CreateSqsQueueTrait.php b/pkg/sqs/Tests/Spec/CreateSqsQueueTrait.php new file mode 100644 index 000000000..3af2a5129 --- /dev/null +++ b/pkg/sqs/Tests/Spec/CreateSqsQueueTrait.php @@ -0,0 +1,21 @@ +queue = $context->createQueue($queueName); + $context->declareQueue($this->queue); + + return $this->queue; + } +} diff --git a/pkg/sqs/Tests/Spec/SqsMessageTest.php b/pkg/sqs/Tests/Spec/SqsMessageTest.php new file mode 100644 index 000000000..994fe5be5 --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsMessageTest.php @@ -0,0 +1,14 @@ +buildSqsContext()->createProducer(); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendAndReceiveDelayedMessageFromQueueTest.php b/pkg/sqs/Tests/Spec/SqsSendAndReceiveDelayedMessageFromQueueTest.php new file mode 100644 index 000000000..40f20d68f --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendAndReceiveDelayedMessageFromQueueTest.php @@ -0,0 +1,46 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createQueue(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromQueueTest.php b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromQueueTest.php new file mode 100644 index 000000000..db698017d --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromQueueTest.php @@ -0,0 +1,46 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createQueue(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromTopicTest.php b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromTopicTest.php new file mode 100644 index 000000000..5cd14468a --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveFromTopicTest.php @@ -0,0 +1,46 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createTopic(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromQueueTest.php b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..7e31a25a4 --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,46 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createQueue(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromTopicTest.php b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromTopicTest.php new file mode 100644 index 000000000..34f2e75dd --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToAndReceiveNoWaitFromTopicTest.php @@ -0,0 +1,46 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createTopic(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveFromQueueTest.php b/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveFromQueueTest.php new file mode 100644 index 000000000..b8e60aee9 --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveFromQueueTest.php @@ -0,0 +1,51 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createTopic(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } + + protected function createQueue(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..e5520e01f --- /dev/null +++ b/pkg/sqs/Tests/Spec/SqsSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,51 @@ +context && $this->queue) { + $this->context->deleteQueue($this->queue); + } + } + + protected function createContext(): SqsContext + { + return $this->context = $this->buildSqsContext(); + } + + protected function createTopic(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } + + protected function createQueue(Context $context, $queueName): SqsDestination + { + return $this->createSqsQueue($context, $queueName); + } +} diff --git a/pkg/sqs/Tests/SqsClientTest.php b/pkg/sqs/Tests/SqsClientTest.php new file mode 100644 index 000000000..ff6a966d4 --- /dev/null +++ b/pkg/sqs/Tests/SqsClientTest.php @@ -0,0 +1,311 @@ + [ + 'key' => '', + 'secret' => '', + 'region' => 'us-west-2', + 'version' => '2012-11-05', + 'endpoint' => 'http://localhost', + ]]))->createSqs(); + + $client = new SqsClient($awsClient); + + $this->assertSame($awsClient, $client->getAWSClient()); + } + + public function testShouldAllowGetAwsClientIfMultipleClientProvided() + { + $awsClient = (new Sdk(['Sqs' => [ + 'key' => '', + 'secret' => '', + 'region' => 'us-west-2', + 'version' => '2012-11-05', + 'endpoint' => 'http://localhost', + ]]))->createMultiRegionSqs(); + + $client = new SqsClient($awsClient); + + $this->assertInstanceOf(AwsSqsClient::class, $client->getAWSClient()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SqsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testLazyApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SqsClient(function () use ($awsClient) { + return $awsClient; + }); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SqsClient(new \stdClass()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sqs\SqsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidLazyInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SqsClient(function () { return new \stdClass(); }); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sqs\SqsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCallWithMultiClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SqsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithSingleClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->never()) + ->method($method) + ; + + $client = new SqsClient($awsClient); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot send message to another region because transport is configured with single aws client'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithMultiClientAndEmptyCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $expectedArgs = $args; + $args['@region'] = ''; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($expectedArgs)) + ->willReturn(new Result($result)); + + $client = new SqsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + public function provideApiCallsSingleClient() + { + yield [ + 'deleteMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'receiveMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'purgeQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'getQueueUrl', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'getQueueAttributes', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'createQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'deleteQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + + yield [ + 'sendMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + } + + public function provideApiCallsMultipleClient() + { + yield [ + 'deleteMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'receiveMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'purgeQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'getQueueUrl', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'getQueueAttributes', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'createQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'deleteQueue', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'sendMessage', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + } +} diff --git a/pkg/sqs/Tests/SqsConnectionFactoryConfigTest.php b/pkg/sqs/Tests/SqsConnectionFactoryConfigTest.php new file mode 100644 index 000000000..c7a954b0f --- /dev/null +++ b/pkg/sqs/Tests/SqsConnectionFactoryConfigTest.php @@ -0,0 +1,235 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string, null or instance of Aws\Sqs\SqsClient'); + + new SqsConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be "sqs"'); + + new SqsConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new SqsConnectionFactory('foo'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new SqsConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + 'sqs:', + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + [], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + 'sqs:?key=theKey&secret=theSecret&token=theToken&lazy=0', + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => false, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sqs:?key=theKey&secret=theSecret&token=theToken&lazy=0'], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => false, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sqs:?profile=staging&lazy=0'], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => false, + 'endpoint' => null, + 'profile' => 'staging', + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + ['key' => 'theKey', 'secret' => 'theSecret', 'token' => 'theToken', 'lazy' => false], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => false, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + ], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + [ + 'profile' => 'staging', + ], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => 'staging', + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], + ]; + + yield [ + ['dsn' => 'sqs:?http[timeout]=5&http[connect_timeout]=2'], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'lazy' => true, + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [ + 'timeout' => '5', + 'connect_timeout' => '2', + ], + ], + ]; + } +} diff --git a/pkg/sqs/Tests/SqsConnectionFactoryTest.php b/pkg/sqs/Tests/SqsConnectionFactoryTest.php new file mode 100644 index 000000000..c327522c5 --- /dev/null +++ b/pkg/sqs/Tests/SqsConnectionFactoryTest.php @@ -0,0 +1,89 @@ +assertClassImplements(ConnectionFactory::class, SqsConnectionFactory::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $factory = new SqsConnectionFactory([]); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $factory = new SqsConnectionFactory(['key' => 'theKey']); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => 'theKey', + 'secret' => null, + 'token' => null, + 'region' => null, + 'retries' => 3, + 'version' => '2012-11-05', + 'endpoint' => null, + 'profile' => null, + 'queue_owner_aws_account_id' => null, + 'http' => [], + ], 'config', $factory); + } + + public function testCouldBeConstructedWithClient() + { + $awsClient = $this->createMock(AwsSqsClient::class); + + $factory = new SqsConnectionFactory($awsClient); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SqsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SqsClient::class, $client); + $this->assertAttributeSame($awsClient, 'inputClient', $client); + } + + public function testShouldCreateLazyContext() + { + $factory = new SqsConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SqsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SqsClient::class, $client); + $this->assertAttributeInstanceOf(\Closure::class, 'inputClient', $client); + } +} diff --git a/pkg/sqs/Tests/SqsConsumerTest.php b/pkg/sqs/Tests/SqsConsumerTest.php new file mode 100644 index 000000000..ef06c6157 --- /dev/null +++ b/pkg/sqs/Tests/SqsConsumerTest.php @@ -0,0 +1,471 @@ +assertClassImplements(Consumer::class, SqsConsumer::class); + } + + public function testShouldReturnInstanceOfDestination() + { + $destination = new SqsDestination('queue'); + + $consumer = new SqsConsumer($this->createContextMock(), $destination); + + $this->assertSame($destination, $consumer->getQueue()); + } + + public function testAcknowledgeShouldThrowIfInstanceOfMessageIsInvalid() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Sqs\SqsMessage but it is Mock_Message'); + + $consumer = new SqsConsumer($this->createContextMock(), new SqsDestination('queue')); + $consumer->acknowledge($this->createMock(Message::class)); + } + + public function testCouldAcknowledgeMessage() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('deleteMessage') + ->with($this->identicalTo([ + '@region' => null, + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + + $consumer = new SqsConsumer($context, new SqsDestination('queue')); + $consumer->acknowledge($message); + } + + public function testCouldAcknowledgeMessageWithCustomRegion() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('deleteMessage') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + + $destination = new SqsDestination('queue'); + $destination->setRegion('theRegion'); + + $consumer = new SqsConsumer($context, $destination); + $consumer->acknowledge($message); + } + + public function testRejectShouldThrowIfInstanceOfMessageIsInvalid() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message must be an instance of Enqueue\Sqs\SqsMessage but it is Mock_Message'); + + $consumer = new SqsConsumer($this->createContextMock(), new SqsDestination('queue')); + $consumer->reject($this->createMock(Message::class)); + } + + public function testShouldRejectMessage() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('deleteMessage') + ->with($this->identicalTo([ + '@region' => null, + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->never()) + ->method('createProducer') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + + $consumer = new SqsConsumer($context, new SqsDestination('queue')); + $consumer->reject($message); + } + + public function testShouldRejectMessageWithCustomRegion() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('deleteMessage') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->never()) + ->method('createProducer') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + + $destination = new SqsDestination('queue'); + $destination->setRegion('theRegion'); + + $consumer = new SqsConsumer($context, $destination); + $consumer->reject($message); + } + + public function testShouldRejectMessageAndRequeue() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('changeMessageVisibility') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + 'VisibilityTimeout' => 0, + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->never()) + ->method('createProducer') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + + $destination = new SqsDestination('queue'); + $destination->setRegion('theRegion'); + + $consumer = new SqsConsumer($context, $destination); + $consumer->reject($message, true); + } + + public function testShouldRejectMessageAndRequeueWithVisibilityTimeout() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('changeMessageVisibility') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'ReceiptHandle' => 'theReceipt', + 'VisibilityTimeout' => 30, + ])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->never()) + ->method('createProducer') + ; + + $message = new SqsMessage(); + $message->setReceiptHandle('theReceipt'); + $message->setRequeueVisibilityTimeout(30); + + $destination = new SqsDestination('queue'); + $destination->setRegion('theRegion'); + + $consumer = new SqsConsumer($context, $destination); + $consumer->reject($message, true); + } + + public function testShouldReceiveMessage() + { + $expectedAttributes = [ + '@region' => null, + 'AttributeNames' => ['All'], + 'MessageAttributeNames' => ['All'], + 'MaxNumberOfMessages' => 1, + 'QueueUrl' => 'theQueueUrl', + 'WaitTimeSeconds' => 0, + ]; + + $expectedSqsMessage = [ + 'Body' => 'The Body', + 'ReceiptHandle' => 'The Receipt', + 'MessageId' => 'theMessageId', + 'Attributes' => [ + 'SenderId' => 'AROAX5IAWYILCTYIS3OZ5:foo@bar.com', + 'ApproximateFirstReceiveTimestamp' => '1560512269481', + 'ApproximateReceiveCount' => '3', + 'SentTimestamp' => '1560512260079', + ], + 'MessageAttributes' => [ + 'Headers' => [ + 'StringValue' => json_encode([['hkey' => 'hvalue'], ['key' => 'value']]), + 'DataType' => 'String', + ], + ], + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('receiveMessage') + ->with($this->identicalTo($expectedAttributes)) + ->willReturn(new Result(['Messages' => [$expectedSqsMessage]])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn(new SqsMessage()) + ; + + $consumer = new SqsConsumer($context, new SqsDestination('queue')); + $result = $consumer->receiveNoWait(); + + $this->assertInstanceOf(SqsMessage::class, $result); + $this->assertEquals('The Body', $result->getBody()); + $this->assertEquals(['hkey' => 'hvalue', 'message_id' => 'theMessageId'], $result->getHeaders()); + $this->assertEquals(['key' => 'value'], $result->getProperties()); + $this->assertEquals([ + 'SenderId' => 'AROAX5IAWYILCTYIS3OZ5:foo@bar.com', + 'ApproximateFirstReceiveTimestamp' => '1560512269481', + 'ApproximateReceiveCount' => '3', + 'SentTimestamp' => '1560512260079', + ], $result->getAttributes()); + $this->assertTrue($result->isRedelivered()); + $this->assertEquals('The Receipt', $result->getReceiptHandle()); + $this->assertEquals('theMessageId', $result->getMessageId()); + } + + public function testShouldReceiveMessageWithCustomRegion() + { + $expectedAttributes = [ + '@region' => 'theRegion', + 'AttributeNames' => ['All'], + 'MessageAttributeNames' => ['All'], + 'MaxNumberOfMessages' => 1, + 'QueueUrl' => 'theQueueUrl', + 'WaitTimeSeconds' => 0, + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('receiveMessage') + ->with($this->identicalTo($expectedAttributes)) + ->willReturn(new Result(['Messages' => [[ + 'Body' => 'The Body', + 'ReceiptHandle' => 'The Receipt', + 'MessageId' => 'theMessageId', + 'Attributes' => [ + 'ApproximateReceiveCount' => 3, + ], + 'MessageAttributes' => [ + 'Headers' => [ + 'StringValue' => json_encode([['hkey' => 'hvalue'], ['key' => 'value']]), + 'DataType' => 'String', + ], + ], + ]]])) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('createMessage') + ->willReturn(new SqsMessage()) + ; + + $destination = new SqsDestination('queue'); + $destination->setRegion('theRegion'); + + $consumer = new SqsConsumer($context, $destination); + $result = $consumer->receiveNoWait(); + + $this->assertInstanceOf(SqsMessage::class, $result); + } + + public function testShouldReturnNullIfThereIsNoNewMessage() + { + $expectedAttributes = [ + '@region' => null, + 'AttributeNames' => ['All'], + 'MessageAttributeNames' => ['All'], + 'MaxNumberOfMessages' => 1, + 'QueueUrl' => 'theQueueUrl', + 'WaitTimeSeconds' => 10, + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('receiveMessage') + ->with($this->identicalTo($expectedAttributes)) + ->willReturn(new Result()) + ; + + $context = $this->createContextMock(); + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->never()) + ->method('createMessage') + ; + + $consumer = new SqsConsumer($context, new SqsDestination('queue')); + $result = $consumer->receive(10000); + + $this->assertNull($result); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsProducer + */ + private function createProducerMock(): SqsProducer + { + return $this->createMock(SqsProducer::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsClient + */ + private function createSqsClientMock(): SqsClient + { + return $this->createMock(SqsClient::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsContext + */ + private function createContextMock(): SqsContext + { + return $this->createMock(SqsContext::class); + } +} diff --git a/pkg/sqs/Tests/SqsContextTest.php b/pkg/sqs/Tests/SqsContextTest.php new file mode 100644 index 000000000..5081add41 --- /dev/null +++ b/pkg/sqs/Tests/SqsContextTest.php @@ -0,0 +1,439 @@ +assertClassImplements(Context::class, SqsContext::class); + } + + public function testShouldAllowCreateEmptyMessage() + { + $context = new SqsContext($this->createSqsClientMock(), []); + + $message = $context->createMessage(); + + $this->assertInstanceOf(SqsMessage::class, $message); + + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + } + + public function testShouldAllowCreateCustomMessage() + { + $context = new SqsContext($this->createSqsClientMock(), []); + + $message = $context->createMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $this->assertInstanceOf(SqsMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testShouldCreateQueue() + { + $context = new SqsContext($this->createSqsClientMock(), [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueue'); + + $this->assertInstanceOf(SqsDestination::class, $queue); + $this->assertSame('aQueue', $queue->getQueueName()); + } + + public function testShouldAllowCreateTopic() + { + $context = new SqsContext($this->createSqsClientMock(), [ + 'queue_owner_aws_account_id' => null, + ]); + + $topic = $context->createTopic('aTopic'); + + $this->assertInstanceOf(SqsDestination::class, $topic); + $this->assertSame('aTopic', $topic->getTopicName()); + } + + public function testThrowNotImplementedOnCreateTmpQueueCall() + { + $context = new SqsContext($this->createSqsClientMock(), []); + + $this->expectException(TemporaryQueueNotSupportedException::class); + + $context->createTemporaryQueue(); + } + + public function testShouldCreateProducer() + { + $context = new SqsContext($this->createSqsClientMock(), []); + + $producer = $context->createProducer(); + + $this->assertInstanceOf(SqsProducer::class, $producer); + } + + public function testShouldThrowIfNotSqsDestinationGivenOnCreateConsumer() + { + $context = new SqsContext($this->createSqsClientMock(), []); + + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Sqs\SqsDestination but got Mock_Queue'); + + $context->createConsumer($this->createMock(Queue::class)); + } + + public function testShouldCreateConsumer() + { + $context = new SqsContext($this->createSqsClientMock(), [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueue'); + + $consumer = $context->createConsumer($queue); + + $this->assertInstanceOf(SqsConsumer::class, $consumer); + } + + public function testShouldAllowDeclareQueue() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('createQueue') + ->with($this->identicalTo([ + '@region' => null, + 'Attributes' => [], + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + + $context->declareQueue($queue); + } + + public function testShouldAllowDeclareQueueWithCustomRegion() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('createQueue') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'Attributes' => [], + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + $queue->setRegion('theRegion'); + + $context->declareQueue($queue); + } + + public function testShouldAllowDeleteQueue() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('deleteQueue') + ->with($this->identicalTo(['QueueUrl' => 'theQueueUrl'])) + ->willReturn(new Result()) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + + $context->deleteQueue($queue); + } + + public function testShouldAllowDeleteQueueWithCustomRegion() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('deleteQueue') + ->with($this->identicalTo(['QueueUrl' => 'theQueueUrl'])) + ->willReturn(new Result()) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + $queue->setRegion('theRegion'); + + $context->deleteQueue($queue); + } + + public function testShouldAllowPurgeQueue() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('purgeQueue') + ->with($this->identicalTo([ + '@region' => null, + 'QueueUrl' => 'theQueueUrl', + ])) + ->willReturn(new Result()) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + + $context->purgeQueue($queue); + } + + public function testShouldAllowPurgeQueueWithCustomRegion() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('purgeQueue') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + ])) + ->willReturn(new Result()) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = $context->createQueue('aQueueName'); + $queue->setRegion('theRegion'); + + $context->purgeQueue($queue); + } + + public function testShouldAllowGetQueueUrl() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $context->getQueueUrl(new SqsDestination('aQueueName')); + } + + public function testShouldAllowGetQueueArn() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('getQueueAttributes') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'AttributeNames' => ['QueueArn'], + ])) + ->willReturn(new Result([ + 'Attributes' => [ + 'QueueArn' => 'theQueueArn', + ], + ])) + ; + + $context = new SqsContext($sqsClient, []); + + $queue = $context->createQueue('aQueueName'); + $queue->setRegion('theRegion'); + + $this->assertSame('theQueueArn', $context->getQueueArn($queue)); + } + + public function testShouldAllowGetQueueUrlWithCustomRegion() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = new SqsDestination('aQueueName'); + $queue->setRegion('theRegion'); + + $context->getQueueUrl($queue); + } + + public function testShouldAllowGetQueueUrlFromAnotherAWSAccountSetGlobally() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + 'QueueOwnerAWSAccountId' => 'anotherAWSAccountID', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => 'anotherAWSAccountID', + ]); + + $context->getQueueUrl(new SqsDestination('aQueueName')); + } + + public function testShouldAllowGetQueueUrlFromAnotherAWSAccountSetPerQueue() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + 'QueueOwnerAWSAccountId' => 'anotherAWSAccountID', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $queue = new SqsDestination('aQueueName'); + $queue->setQueueOwnerAWSAccountId('anotherAWSAccountID'); + + $context->getQueueUrl($queue); + } + + public function testShouldThrowExceptionIfGetQueueUrlResultHasNoQueueUrlProperty() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => null, + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result([])) + ; + + $context = new SqsContext($sqsClient, [ + 'queue_owner_aws_account_id' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('QueueUrl cannot be resolved. queueName: "aQueueName"'); + + $context->getQueueUrl(new SqsDestination('aQueueName')); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsClient + */ + private function createSqsClientMock(): SqsClient + { + return $this->createMock(SqsClient::class); + } +} diff --git a/pkg/sqs/Tests/SqsDestinationTest.php b/pkg/sqs/Tests/SqsDestinationTest.php new file mode 100644 index 000000000..724ce0c42 --- /dev/null +++ b/pkg/sqs/Tests/SqsDestinationTest.php @@ -0,0 +1,106 @@ +assertClassImplements(Topic::class, SqsDestination::class); + $this->assertClassImplements(Queue::class, SqsDestination::class); + } + + public function testShouldReturnNameSetInConstructor() + { + $destination = new SqsDestination('aDestinationName'); + + $this->assertSame('aDestinationName', $destination->getQueueName()); + $this->assertSame('aDestinationName', $destination->getTopicName()); + } + + public function testCouldSetDelaySecondsAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setDelaySeconds(12345); + + $this->assertSame(['DelaySeconds' => 12345], $destination->getAttributes()); + } + + public function testCouldSetMaximumMessageSizeAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setMaximumMessageSize(12345); + + $this->assertSame(['MaximumMessageSize' => 12345], $destination->getAttributes()); + } + + public function testCouldSetMessageRetentionPeriodAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setMessageRetentionPeriod(12345); + + $this->assertSame(['MessageRetentionPeriod' => 12345], $destination->getAttributes()); + } + + public function testCouldSetPolicyAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setPolicy('thePolicy'); + + $this->assertSame(['Policy' => 'thePolicy'], $destination->getAttributes()); + } + + public function testCouldSetReceiveMessageWaitTimeSecondsAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setReceiveMessageWaitTimeSeconds(12345); + + $this->assertSame(['ReceiveMessageWaitTimeSeconds' => 12345], $destination->getAttributes()); + } + + public function testCouldSetRedrivePolicyAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setRedrivePolicy(12345, 'theDeadQueueArn'); + + $this->assertSame(['RedrivePolicy' => '{"maxReceiveCount":"12345","deadLetterTargetArn":"theDeadQueueArn"}'], $destination->getAttributes()); + } + + public function testCouldSetVisibilityTimeoutAttribute() + { + $destination = new SqsDestination('aDestinationName'); + $destination->setVisibilityTimeout(12345); + + $this->assertSame(['VisibilityTimeout' => 12345], $destination->getAttributes()); + } + + public function testCouldSetFifoQueueAttributeAndUnsetIt() + { + $destination = new SqsDestination('aDestinationName'); + + $destination->setFifoQueue(true); + $this->assertSame(['FifoQueue' => 'true'], $destination->getAttributes()); + + $destination->setFifoQueue(false); + $this->assertSame([], $destination->getAttributes()); + } + + public function testCouldSetContentBasedDeduplicationAttributeAndUnsetIt() + { + $destination = new SqsDestination('aDestinationName'); + + $destination->setContentBasedDeduplication(true); + $this->assertSame(['ContentBasedDeduplication' => 'true'], $destination->getAttributes()); + + $destination->setContentBasedDeduplication(false); + $this->assertSame([], $destination->getAttributes()); + } +} diff --git a/pkg/sqs/Tests/SqsMessageTest.php b/pkg/sqs/Tests/SqsMessageTest.php new file mode 100644 index 000000000..5da37b531 --- /dev/null +++ b/pkg/sqs/Tests/SqsMessageTest.php @@ -0,0 +1,108 @@ +assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); + $this->assertSame([], $message->getAttributes()); + } + + public function testCouldBeConstructedWithOptionalArguments() + { + $message = new SqsMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); + } + + public function testShouldSetCorrelationIdAsHeader() + { + $message = new SqsMessage(); + $message->setCorrelationId('the-correlation-id'); + + $this->assertSame(['correlation_id' => 'the-correlation-id'], $message->getHeaders()); + } + + public function testShouldSetMessageIdAsHeader() + { + $message = new SqsMessage(); + $message->setMessageId('the-message-id'); + + $this->assertSame(['message_id' => 'the-message-id'], $message->getHeaders()); + } + + public function testShouldSetTimestampAsHeader() + { + $message = new SqsMessage(); + $message->setTimestamp(12345); + + $this->assertSame(['timestamp' => 12345], $message->getHeaders()); + } + + public function testShouldSetReplyToAsHeader() + { + $message = new SqsMessage(); + $message->setReplyTo('theQueueName'); + + $this->assertSame(['reply_to' => 'theQueueName'], $message->getHeaders()); + } + + public function testShouldAllowGetDelaySeconds() + { + $message = new SqsMessage(); + $message->setDelaySeconds(12345); + + $this->assertSame(12345, $message->getDelaySeconds()); + } + + public function testShouldAllowGetMessageDeduplicationId() + { + $message = new SqsMessage(); + $message->setMessageDeduplicationId('theId'); + + $this->assertSame('theId', $message->getMessageDeduplicationId()); + } + + public function testShouldAllowGetMessageGroupId() + { + $message = new SqsMessage(); + $message->setMessageGroupId('theId'); + + $this->assertSame('theId', $message->getMessageGroupId()); + } + + public function testShouldAllowGetReceiptHandle() + { + $message = new SqsMessage(); + $message->setReceiptHandle('theId'); + + $this->assertSame('theId', $message->getReceiptHandle()); + } + + public function testShouldAllowSettingAndGettingAttributes() + { + $message = new SqsMessage(); + $message->setAttributes($attributes = [ + 'SenderId' => 'AROAX5IAWYILCTYIS3OZ5:foo@bar.com', + 'ApproximateFirstReceiveTimestamp' => '1560512269481', + 'ApproximateReceiveCount' => '2', + 'SentTimestamp' => '1560512260079', + ]); + + $this->assertSame($attributes, $message->getAttributes()); + $this->assertSame($attributes['SenderId'], $message->getAttribute('SenderId')); + } +} diff --git a/pkg/sqs/Tests/SqsProducerTest.php b/pkg/sqs/Tests/SqsProducerTest.php new file mode 100644 index 000000000..35cb9850b --- /dev/null +++ b/pkg/sqs/Tests/SqsProducerTest.php @@ -0,0 +1,233 @@ +assertClassImplements(Producer::class, SqsProducer::class); + } + + public function testShouldThrowIfBodyOfInvalidType() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message body must be a non-empty string.'); + + $producer = new SqsProducer($this->createSqsContextMock()); + + $message = new SqsMessage(''); + + $producer->send(new SqsDestination(''), $message); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Sqs\SqsDestination but got Mock_Destinat'); + + $producer = new SqsProducer($this->createSqsContextMock()); + + $producer->send($this->createMock(Destination::class), new SqsMessage()); + } + + public function testShouldThrowIfSendMessageFailed() + { + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('sendMessage') + ->willReturn(new Result()) + ; + + $context = $this->createSqsContextMock(); + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + + $destination = new SqsDestination('queue-name'); + $message = new SqsMessage('foo'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Message was not sent'); + + $producer = new SqsProducer($context); + $producer->send($destination, $message); + } + + public function testShouldSendMessage() + { + $expectedArguments = [ + '@region' => null, + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[{"hkey":"hvaleu"},{"key":"value"}]', + ], + ], + 'MessageBody' => 'theBody', + 'QueueUrl' => 'theQueueUrl', + 'DelaySeconds' => 12345, + 'MessageDeduplicationId' => 'theDeduplicationId', + 'MessageGroupId' => 'groupId', + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('sendMessage') + ->with($this->identicalTo($expectedArguments)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])) + ; + + $context = $this->createSqsContextMock(); + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + + $destination = new SqsDestination('queue-name'); + $message = new SqsMessage('theBody', ['key' => 'value'], ['hkey' => 'hvaleu']); + $message->setDelaySeconds(12345); + $message->setMessageDeduplicationId('theDeduplicationId'); + $message->setMessageGroupId('groupId'); + + $producer = new SqsProducer($context); + $producer->send($destination, $message); + } + + public function testShouldSendMessageWithCustomRegion() + { + $expectedArguments = [ + '@region' => 'theRegion', + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[[],[]]', + ], + ], + 'MessageBody' => 'theBody', + 'QueueUrl' => 'theQueueUrl', + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('sendMessage') + ->with($this->identicalTo($expectedArguments)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])) + ; + + $context = $this->createSqsContextMock(); + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + + $destination = new SqsDestination('queue-name'); + $destination->setRegion('theRegion'); + + $message = new SqsMessage('theBody'); + + $producer = new SqsProducer($context); + $producer->send($destination, $message); + } + + public function testShouldSendDelayedMessage() + { + $expectedArguments = [ + '@region' => null, + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[{"hkey":"hvaleu"},{"key":"value"}]', + ], + ], + 'MessageBody' => 'theBody', + 'QueueUrl' => 'theQueueUrl', + 'DelaySeconds' => 12345, + 'MessageDeduplicationId' => 'theDeduplicationId', + 'MessageGroupId' => 'groupId', + ]; + + $client = $this->createSqsClientMock(); + $client + ->expects($this->once()) + ->method('sendMessage') + ->with($this->identicalTo($expectedArguments)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])) + ; + + $context = $this->createSqsContextMock(); + $context + ->expects($this->once()) + ->method('getQueueUrl') + ->willReturn('theQueueUrl') + ; + $context + ->expects($this->once()) + ->method('getSqsClient') + ->willReturn($client) + ; + + $destination = new SqsDestination('queue-name'); + $message = new SqsMessage('theBody', ['key' => 'value'], ['hkey' => 'hvaleu']); + $message->setDelaySeconds(12345); + $message->setMessageDeduplicationId('theDeduplicationId'); + $message->setMessageGroupId('groupId'); + + $producer = new SqsProducer($context); + $producer->setDeliveryDelay(5000); + $producer->send($destination, $message); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsContext + */ + private function createSqsContextMock(): SqsContext + { + return $this->createMock(SqsContext::class); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|SqsClient + */ + private function createSqsClientMock(): SqsClient + { + return $this->createMock(SqsClient::class); + } +} diff --git a/pkg/sqs/composer.json b/pkg/sqs/composer.json new file mode 100644 index 000000000..2ddc1b267 --- /dev/null +++ b/pkg/sqs/composer.json @@ -0,0 +1,38 @@ +{ + "name": "enqueue/sqs", + "type": "library", + "description": "Message Queue Amazon SQS Transport", + "keywords": ["messaging", "queue", "amazon", "aws", "sqs"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/queue-interop": "^0.8", + "enqueue/dsn": "^0.10", + "aws/aws-sdk-php": "^3.290" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Sqs\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + } +} diff --git a/pkg/sqs/examples/consume.php b/pkg/sqs/examples/consume.php new file mode 100644 index 000000000..d82274d80 --- /dev/null +++ b/pkg/sqs/examples/consume.php @@ -0,0 +1,33 @@ +createContext(); + +$queue = $context->createQueue('enqueue'); +$consumer = $context->createConsumer($queue); + +while (true) { + if ($m = $consumer->receive(20000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().\PHP_EOL; + } +} + +echo 'Done'."\n"; diff --git a/pkg/sqs/examples/produce.php b/pkg/sqs/examples/produce.php new file mode 100644 index 000000000..a9ba3e3b7 --- /dev/null +++ b/pkg/sqs/examples/produce.php @@ -0,0 +1,34 @@ +createContext(); + +$queue = $context->createQueue('enqueue'); +$message = $context->createMessage('Hello Bar!'); + +$context->declareQueue($queue); + +while (true) { + $context->createProducer()->send($queue, $message); + echo 'Sent message: '.$message->getBody().\PHP_EOL; + sleep(1); +} + +echo 'Done'."\n"; diff --git a/pkg/sqs/phpunit.xml.dist b/pkg/sqs/phpunit.xml.dist new file mode 100644 index 000000000..8fbb94ebf --- /dev/null +++ b/pkg/sqs/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/stomp/.gitattributes b/pkg/stomp/.gitattributes new file mode 100644 index 000000000..3fab2dac1 --- /dev/null +++ b/pkg/stomp/.gitattributes @@ -0,0 +1,6 @@ +/examples export-ignore +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/stomp/.github/workflows/ci.yml b/pkg/stomp/.github/workflows/ci.yml new file mode 100644 index 000000000..0492424e8 --- /dev/null +++ b/pkg/stomp/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + with: + composer-options: "--prefer-source" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/stomp/.travis.yml b/pkg/stomp/.travis.yml deleted file mode 100644 index 42374ddc7..000000000 --- a/pkg/stomp/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false - -git: - depth: 1 - -language: php - -php: - - '5.6' - - '7.0' - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer self-update - - composer install --prefer-source - -script: - - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/stomp/BufferedStompClient.php b/pkg/stomp/BufferedStompClient.php index 90c70ead3..e2c54d731 100644 --- a/pkg/stomp/BufferedStompClient.php +++ b/pkg/stomp/BufferedStompClient.php @@ -3,6 +3,7 @@ namespace Enqueue\Stomp; use Stomp\Client; +use Stomp\Transport\Frame; class BufferedStompClient extends Client { @@ -47,12 +48,9 @@ public function getBufferSize() } /** - * @param string $subscriptionId - * @param int|float $timeout - * - * @return \Stomp\Transport\Frame + * Timeout is in milliseconds. */ - public function readMessageFrame($subscriptionId, $timeout) + public function readMessageFrame(string $subscriptionId, int $timeout): ?Frame { // pop up frame from the buffer if (isset($this->buffer[$subscriptionId]) && ($frame = array_shift($this->buffer[$subscriptionId]))) { @@ -63,21 +61,21 @@ public function readMessageFrame($subscriptionId, $timeout) // do nothing when buffer is full if ($this->currentBufferSize >= $this->bufferSize) { - return; + return null; } $startTime = microtime(true); - $remainingTimeout = $timeout * 1000000; + $remainingTimeout = $timeout * 1000; while (true) { $this->getConnection()->setReadTimeout(0, $remainingTimeout); // there is nothing to read if (false === $frame = $this->readFrame()) { - return; + return null; } - if ($frame->getCommand() !== 'MESSAGE') { + if ('MESSAGE' !== $frame->getCommand()) { throw new \LogicException(sprintf('Unexpected frame was received: "%s"', $frame->getCommand())); } @@ -95,7 +93,7 @@ public function readMessageFrame($subscriptionId, $timeout) $remainingTimeout -= (microtime(true) - $startTime) * 1000000; if ($remainingTimeout <= 0) { - return; + return null; } continue; @@ -105,9 +103,6 @@ public function readMessageFrame($subscriptionId, $timeout) } } - /** - * {@inheritdoc} - */ public function disconnect($sync = false) { parent::disconnect($sync); diff --git a/pkg/stomp/Client/ManagementClient.php b/pkg/stomp/Client/ManagementClient.php deleted file mode 100644 index be4e2da12..000000000 --- a/pkg/stomp/Client/ManagementClient.php +++ /dev/null @@ -1,77 +0,0 @@ -client = $client; - $this->vhost = $vhost; - } - - /** - * @param string $vhost - * @param string $host - * @param int $port - * @param string $login - * @param string $password - * - * @return ManagementClient - */ - public static function create($vhost = '/', $host = 'localhost', $port = 15672, $login = 'guest', $password = 'guest') - { - return new static(new Client(null, 'http://'.$host.':'.$port, $login, $password), $vhost); - } - - /** - * @param string $name - * @param array $options - * - * @return array - */ - public function declareQueue($name, $options) - { - return $this->client->queues()->create($this->vhost, $name, $options); - } - - /** - * @param string $name - * @param array $options - * - * @return array - */ - public function declareExchange($name, $options) - { - return $this->client->exchanges()->create($this->vhost, $name, $options); - } - - /** - * @param string $exchange - * @param string $queue - * @param string $routingKey - * @param array $arguments - * - * @return array - */ - public function bind($exchange, $queue, $routingKey, $arguments = null) - { - return $this->client->bindings()->create($this->vhost, $exchange, $queue, $routingKey, $arguments); - } -} diff --git a/pkg/stomp/Client/RabbitMqStompDriver.php b/pkg/stomp/Client/RabbitMqStompDriver.php deleted file mode 100644 index 9a006b054..000000000 --- a/pkg/stomp/Client/RabbitMqStompDriver.php +++ /dev/null @@ -1,269 +0,0 @@ -context = $context; - $this->config = $config; - $this->queueMetaRegistry = $queueMetaRegistry; - $this->management = $management; - - $this->priorityMap = [ - MessagePriority::VERY_LOW => 0, - MessagePriority::LOW => 1, - MessagePriority::NORMAL => 2, - MessagePriority::HIGH => 3, - MessagePriority::VERY_HIGH => 4, - ]; - } - - /** - * {@inheritdoc} - * - * @return StompMessage - */ - public function createTransportMessage(Message $message) - { - $transportMessage = parent::createTransportMessage($message); - - if ($message->getExpire()) { - $transportMessage->setHeader('expiration', (string) ($message->getExpire() * 1000)); - } - - if ($priority = $message->getPriority()) { - if (false == array_key_exists($priority, $this->priorityMap)) { - throw new \LogicException(sprintf('Cant convert client priority to transport: "%s"', $priority)); - } - - $transportMessage->setHeader('priority', $this->priorityMap[$priority]); - } - - if ($message->getDelay()) { - if (false == $this->config->getTransportOption('delay_plugin_installed', false)) { - throw new \LogicException('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); - } - - $transportMessage->setHeader('x-delay', (string) ($message->getDelay() * 1000)); - } - - return $transportMessage; - } - - /** - * @param StompMessage $message - * - * {@inheritdoc} - */ - public function createClientMessage(TransportMessage $message) - { - $clientMessage = parent::createClientMessage($message); - - $headers = $clientMessage->getHeaders(); - unset( - $headers['x-delay'], - $headers['expiration'], - $headers['priority'] - ); - $clientMessage->setHeaders($headers); - - if ($delay = $message->getHeader('x-delay')) { - if (false == is_numeric($delay)) { - throw new \LogicException(sprintf('x-delay header is not numeric. "%s"', $delay)); - } - - $clientMessage->setDelay((int) ((int) $delay) / 1000); - } - - if ($expiration = $message->getHeader('expiration')) { - if (false == is_numeric($expiration)) { - throw new \LogicException(sprintf('expiration header is not numeric. "%s"', $expiration)); - } - - $clientMessage->setExpire((int) ((int) $expiration) / 1000); - } - - if ($priority = $message->getHeader('priority')) { - if (false === $clientPriority = array_search($priority, $this->priorityMap, true)) { - throw new \LogicException(sprintf('Cant convert transport priority to client: "%s"', $priority)); - } - - $clientMessage->setPriority($clientPriority); - } - - return $clientMessage; - } - - /** - * {@inheritdoc} - */ - public function sendToProcessor(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_PROCESSOR_NAME)) { - throw new \LogicException('Processor name parameter is required but is not set'); - } - - if (false == $queueName = $message->getProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME)) { - throw new \LogicException('Queue name parameter is required but is not set'); - } - - $transportMessage = $this->createTransportMessage($message); - $destination = $this->createQueue($queueName); - - if ($message->getDelay()) { - $destination = $this->createDelayedTopic($destination); - } - - $this->context->createProducer()->send($destination, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function createQueue($queueName) - { - $queue = parent::createQueue($queueName); - $queue->setHeader('x-max-priority', 4); - - return $queue; - } - - /** - * {@inheritdoc} - */ - public function setupBroker(LoggerInterface $logger = null) - { - $logger = $logger ?: new NullLogger(); - $log = function ($text, ...$args) use ($logger) { - $logger->debug(sprintf('[RabbitMqStompDriver] '.$text, ...$args)); - }; - - if (false == $this->config->getTransportOption('management_plugin_installed', false)) { - $log('Could not setup broker. The option `management_plugin_installed` is not enabled. Please enable that option and install rabbit management plugin'); - - return; - } - - // setup router - $routerExchange = $this->config->createTransportRouterTopicName($this->config->getRouterTopicName()); - $log('Declare router exchange: %s', $routerExchange); - $this->management->declareExchange($routerExchange, [ - 'type' => 'fanout', - 'durable' => true, - 'auto_delete' => false, - ]); - - $routerQueue = $this->config->createTransportQueueName($this->config->getRouterQueueName()); - $log('Declare router queue: %s', $routerQueue); - $this->management->declareQueue($routerQueue, [ - 'auto_delete' => false, - 'durable' => true, - 'arguments' => [ - 'x-max-priority' => 4, - ], - ]); - - $log('Bind router queue to exchange: %s -> %s', $routerQueue, $routerExchange); - $this->management->bind($routerExchange, $routerQueue, $routerQueue); - - // setup queues - foreach ($this->queueMetaRegistry->getQueuesMeta() as $meta) { - $queue = $this->config->createTransportQueueName($meta->getClientName()); - - $log('Declare processor queue: %s', $queue); - $this->management->declareQueue($queue, [ - 'auto_delete' => false, - 'durable' => true, - 'arguments' => [ - 'x-max-priority' => 4, - ], - ]); - } - - // setup delay exchanges - if ($this->config->getTransportOption('delay_plugin_installed', false)) { - foreach ($this->queueMetaRegistry->getQueuesMeta() as $meta) { - $queue = $this->config->createTransportQueueName($meta->getClientName()); - $delayExchange = $queue.'.delayed'; - - $log('Declare delay exchange: %s', $delayExchange); - $this->management->declareExchange($delayExchange, [ - 'type' => 'x-delayed-message', - 'durable' => true, - 'auto_delete' => false, - 'arguments' => [ - 'x-delayed-type' => 'direct', - ], - ]); - - $log('Bind processor queue to delay exchange: %s -> %s', $queue, $delayExchange); - $this->management->bind($delayExchange, $queue, $queue); - } - } else { - $log('Delay exchange and bindings are not setup. if you\'d like to use delays please install delay rabbitmq plugin and set delay_plugin_installed option to true'); - } - } - - /** - * @param StompDestination $queue - * - * @return StompDestination - */ - private function createDelayedTopic(StompDestination $queue) - { - // in order to use delay feature make sure the rabbitmq_delayed_message_exchange plugin is installed. - $destination = $this->context->createTopic($queue->getStompName().'.delayed'); - $destination->setType(StompDestination::TYPE_EXCHANGE); - $destination->setDurable(true); - $destination->setAutoDelete(false); - $destination->setRoutingKey($queue->getStompName()); - - return $destination; - } -} diff --git a/pkg/stomp/Client/StompDriver.php b/pkg/stomp/Client/StompDriver.php deleted file mode 100644 index 6500ae887..000000000 --- a/pkg/stomp/Client/StompDriver.php +++ /dev/null @@ -1,169 +0,0 @@ -context = $context; - $this->config = $config; - } - - /** - * {@inheritdoc} - */ - public function sendToRouter(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_TOPIC_NAME)) { - throw new \LogicException('Topic name parameter is required but is not set'); - } - - $topic = $this->createRouterTopic(); - $transportMessage = $this->createTransportMessage($message); - - $this->context->createProducer()->send($topic, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function sendToProcessor(Message $message) - { - if (false == $message->getProperty(Config::PARAMETER_PROCESSOR_NAME)) { - throw new \LogicException('Processor name parameter is required but is not set'); - } - - if (false == $queueName = $message->getProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME)) { - throw new \LogicException('Queue name parameter is required but is not set'); - } - - $transportMessage = $this->createTransportMessage($message); - $destination = $this->createQueue($queueName); - - $this->context->createProducer()->send($destination, $transportMessage); - } - - /** - * {@inheritdoc} - */ - public function setupBroker(LoggerInterface $logger = null) - { - $logger = $logger ?: new NullLogger(); - $logger->debug('[StompDriver] Stomp protocol does not support broker configuration'); - } - - /** - * @return StompMessage - * - * {@inheritdoc} - */ - public function createTransportMessage(Message $message) - { - $headers = $message->getHeaders(); - $headers['content-type'] = $message->getContentType(); - - $transportMessage = $this->context->createMessage(); - $transportMessage->setHeaders($headers); - $transportMessage->setPersistent(true); - $transportMessage->setBody($message->getBody()); - $transportMessage->setProperties($message->getProperties()); - - if ($message->getMessageId()) { - $transportMessage->setMessageId($message->getMessageId()); - } - - if ($message->getTimestamp()) { - $transportMessage->setTimestamp($message->getTimestamp()); - } - - return $transportMessage; - } - - /** - * @param StompMessage $message - * - * {@inheritdoc} - */ - public function createClientMessage(TransportMessage $message) - { - $clientMessage = new Message(); - - $headers = $message->getHeaders(); - unset( - $headers['content-type'], - $headers['message_id'], - $headers['timestamp'] - ); - - $clientMessage->setHeaders($headers); - $clientMessage->setBody($message->getBody()); - $clientMessage->setProperties($message->getProperties()); - - $clientMessage->setContentType($message->getHeader('content-type')); - - $clientMessage->setMessageId($message->getMessageId()); - $clientMessage->setTimestamp($message->getTimestamp()); - - return $clientMessage; - } - - /** - * {@inheritdoc} - */ - public function createQueue($queueName) - { - $queue = $this->context->createQueue($this->config->createTransportQueueName($queueName)); - $queue->setDurable(true); - $queue->setAutoDelete(false); - $queue->setExclusive(false); - - return $queue; - } - - /** - * {@inheritdoc} - */ - public function getConfig() - { - return $this->config; - } - - /** - * @return StompDestination - */ - private function createRouterTopic() - { - $topic = $this->context->createTopic( - $this->config->createTransportRouterTopicName($this->config->getRouterTopicName()) - ); - $topic->setDurable(true); - $topic->setAutoDelete(false); - - return $topic; - } -} diff --git a/pkg/stomp/ExtensionType.php b/pkg/stomp/ExtensionType.php new file mode 100644 index 000000000..c1c265f68 --- /dev/null +++ b/pkg/stomp/ExtensionType.php @@ -0,0 +1,12 @@ +Supporting Enqueue + +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + # STOMP Transport [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) -[![Build Status](https://travis-ci.org/php-enqueue/stomp.png?branch=master)](https://travis-ci.org/php-enqueue/stomp) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/stomp/ci.yml?branch=master)](https://github.com/php-enqueue/stomp/actions?query=workflow%3ACI) [![Total Downloads](https://poser.pugx.org/enqueue/stomp/d/total.png)](https://packagist.org/packages/enqueue/stomp) [![Latest Stable Version](https://poser.pugx.org/enqueue/stomp/version.png)](https://packagist.org/packages/enqueue/stomp) -This is an implementation of PSR specification. It allows you to send and consume message via STOMP protocol. +This is an implementation of Queue Interop specification. It allows you to send and consume message via STOMP protocol. ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/stomp/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/stomp/StompConnectionFactory.php b/pkg/stomp/StompConnectionFactory.php index 2b304673d..da9c6f9bd 100644 --- a/pkg/stomp/StompConnectionFactory.php +++ b/pkg/stomp/StompConnectionFactory.php @@ -1,12 +1,24 @@ null, + * 'port' => null, + * 'login' => null, + * 'password' => null, + * 'vhost' => null, + * 'buffer_size' => 1000, + * 'connection_timeout' => 1, + * 'sync' => false, + * 'lazy' => true, + * 'ssl_on' => false, + * ]. + * + * or + * + * stomp: + * stomp:?buffer_size=100 + * + * @param array|string|null $config */ - public function __construct(array $config) + public function __construct($config = 'stomp:') { - $this->config = array_replace([ - 'host' => null, - 'port' => null, - 'login' => null, - 'password' => null, - 'vhost' => null, - 'buffer_size' => 1000, - 'connection_timeout' => 1, - 'sync' => false, - ], $config); + if (empty($config) || 'stomp:' === $config) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $this->config = array_replace($this->defaultConfig(), $config); } /** - * {@inheritdoc} - * * @return StompContext */ - public function createContext() + public function createContext(): Context + { + $stomp = $this->config['lazy'] + ? function () { return $this->establishConnection(); } + : $this->establishConnection(); + + $target = $this->config['target']; + $detectTransientConnections = (bool) $this->config['detect_transient_connections']; + + return new StompContext($stomp, $target, $detectTransientConnections); + } + + private function establishConnection(): BufferedStompClient { if (false == $this->stomp) { $config = $this->config; - $uri = 'tcp://'.$config['host'].':'.$config['port']; + $scheme = (true === $config['ssl_on']) ? 'ssl' : 'tcp'; + $uri = $scheme.'://'.$config['host'].':'.$config['port']; $connection = new Connection($uri, $config['connection_timeout']); + $connection->setWriteTimeout($config['write_timeout']); + $connection->setReadTimeout($config['read_timeout']); + + if ($config['send_heartbeat']) { + $connection->getObservers()->addObserver(new HeartbeatEmitter($connection)); + } + + if ($config['receive_heartbeat']) { + $connection->getObservers()->addObserver(new ServerAliveObserver()); + } $this->stomp = new BufferedStompClient($connection, $config['buffer_size']); $this->stomp->setLogin($config['login'], $config['password']); $this->stomp->setVhostname($config['vhost']); $this->stomp->setSync($config['sync']); + $this->stomp->setHeartbeat($config['send_heartbeat'], $config['receive_heartbeat']); $this->stomp->connect(); } - return new StompContext($this->stomp); + return $this->stomp; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('stomp' !== $dsn->getSchemeProtocol()) { + throw new \LogicException('The given DSN is not supported. Must start with "stomp:".'); + } + + $schemeExtension = current($dsn->getSchemeExtensions()); + if (false === $schemeExtension) { + $schemeExtension = ExtensionType::RABBITMQ; + } + + if (false === in_array($schemeExtension, self::SUPPORTED_SCHEMES, true)) { + throw new \LogicException(sprintf('The given DSN is not supported. The scheme extension "%s" provided is not supported. It must be one of %s.', $schemeExtension, implode(', ', self::SUPPORTED_SCHEMES))); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'target' => $schemeExtension, + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'login' => $dsn->getUser(), + 'password' => $dsn->getPassword(), + 'vhost' => null !== $dsn->getPath() ? ltrim($dsn->getPath(), '/') : null, + 'buffer_size' => $dsn->getDecimal('buffer_size'), + 'connection_timeout' => $dsn->getDecimal('connection_timeout'), + 'sync' => $dsn->getBool('sync'), + 'lazy' => $dsn->getBool('lazy'), + 'ssl_on' => $dsn->getBool('ssl_on'), + 'write_timeout' => $dsn->getDecimal('write_timeout'), + 'read_timeout' => $dsn->getDecimal('read_timeout'), + 'send_heartbeat' => $dsn->getDecimal('send_heartbeat'), + 'receive_heartbeat' => $dsn->getDecimal('receive_heartbeat'), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'target' => ExtensionType::RABBITMQ, + 'host' => 'localhost', + 'port' => 61613, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ]; } } diff --git a/pkg/stomp/StompConsumer.php b/pkg/stomp/StompConsumer.php index 873b7a247..5a80be890 100644 --- a/pkg/stomp/StompConsumer.php +++ b/pkg/stomp/StompConsumer.php @@ -1,18 +1,23 @@ stomp = $stomp; @@ -55,16 +56,13 @@ public function __construct(BufferedStompClient $stomp, StompDestination $queue) $this->isSubscribed = false; $this->ackMode = self::ACK_CLIENT_INDIVIDUAL; $this->prefetchCount = 1; - $this->subscriptionId = $queue->getType() == StompDestination::TYPE_TEMP_QUEUE ? + $this->subscriptionId = StompDestination::TYPE_TEMP_QUEUE == $queue->getType() ? $queue->getQueueName() : uniqid('', true) ; } - /** - * @param string $mode - */ - public function setAckMode($mode) + public function setAckMode(string $mode): void { if (false === in_array($mode, [self::ACK_AUTO, self::ACK_CLIENT, self::ACK_CLIENT_INDIVIDUAL], true)) { throw new \LogicException(sprintf('Ack mode is not valid: "%s"', $mode)); @@ -73,78 +71,67 @@ public function setAckMode($mode) $this->ackMode = $mode; } - /** - * @return string - */ - public function getAckMode() + public function getAckMode(): string { return $this->ackMode; } - /** - * @return int - */ - public function getPrefetchCount() + public function getPrefetchCount(): int { return $this->prefetchCount; } - /** - * @param int $prefetchCount - */ - public function setPrefetchCount($prefetchCount) + public function setPrefetchCount(int $prefetchCount): void { - $this->prefetchCount = (int) $prefetchCount; + $this->prefetchCount = $prefetchCount; } /** - * {@inheritdoc} - * * @return StompDestination */ - public function getQueue() + public function getQueue(): Queue { return $this->queue; } - /** - * {@inheritdoc} - */ - public function receive($timeout = 0) + public function receive(int $timeout = 0): ?Message { $this->subscribe(); - if ($timeout === 0) { - while (true) { - if ($message = $this->stomp->readMessageFrame($this->subscriptionId, 0.1)) { + try { + if (0 === $timeout) { + while (true) { + if ($message = $this->stomp->readMessageFrame($this->subscriptionId, 100)) { + return $this->convertMessage($message); + } + } + } else { + if ($message = $this->stomp->readMessageFrame($this->subscriptionId, $timeout)) { return $this->convertMessage($message); } } - } else { - if ($message = $this->stomp->readMessageFrame($this->subscriptionId, $timeout)) { - return $this->convertMessage($message); - } + } catch (ErrorFrameException $e) { + throw new Exception($e->getMessage()."\n".$e->getFrame()->getBody(), 0, $e); } + + return null; } - /** - * {@inheritdoc} - */ - public function receiveNoWait() + public function receiveNoWait(): ?Message { $this->subscribe(); if ($message = $this->stomp->readMessageFrame($this->subscriptionId, 0)) { return $this->convertMessage($message); } + + return null; } /** - * {@inheritdoc} - * * @param StompMessage $message */ - public function acknowledge(Message $message) + public function acknowledge(Message $message): void { InvalidMessageException::assertMessageInstanceOf($message, StompMessage::class); @@ -154,25 +141,24 @@ public function acknowledge(Message $message) } /** - * {@inheritdoc} - * * @param StompMessage $message */ - public function reject(Message $message, $requeue = false) + public function reject(Message $message, bool $requeue = false): void { InvalidMessageException::assertMessageInstanceOf($message, StompMessage::class); $nackFrame = $this->stomp->getProtocol()->getNackFrame($message->getFrame()); - // rabbitmq STOMP protocol extension - $nackFrame->addHeaders([ - 'requeue' => $requeue ? 'true' : 'false', - ]); + if (ExtensionType::RABBITMQ === $this->queue->getExtensionType()) { + $nackFrame->addHeaders([ + 'requeue' => $requeue ? 'true' : 'false', + ]); + } $this->stomp->sendFrame($nackFrame); } - private function subscribe() + private function subscribe(): void { if (StompDestination::TYPE_TEMP_QUEUE == $this->queue->getType()) { $this->isSubscribed = true; @@ -183,28 +169,41 @@ private function subscribe() if (false == $this->isSubscribed) { $this->isSubscribed = true; - $frame = $this->stomp->getProtocol() - ->getSubscribeFrame($this->queue->getQueueName(), $this->subscriptionId, $this->ackMode); + $frame = $this->stomp->getProtocol()->getSubscribeFrame( + $this->queue->getQueueName(), + $this->subscriptionId, + $this->ackMode + ); - // rabbitmq STOMP protocol extension $headers = $this->queue->getHeaders(); - $headers['prefetch-count'] = $this->prefetchCount; - $headers = StompHeadersEncoder::encode($headers); - foreach ($headers as $key => $value) { - $frame[$key] = $value; + if (ExtensionType::RABBITMQ === $this->queue->getExtensionType()) { + $headers['prefetch-count'] = $this->prefetchCount; + $headers = StompHeadersEncoder::encode($headers); + + foreach ($headers as $key => $value) { + $frame[$key] = $value; + } + } elseif (ExtensionType::ARTEMIS === $this->queue->getExtensionType()) { + $subscriptionName = $this->subscriptionId.'-'.$this->queue->getStompName(); + + $artemisHeaders = []; + + $artemisHeaders['client-id'] = true ? $this->subscriptionId : null; + $artemisHeaders['durable-subscription-name'] = true ? $subscriptionName : null; + + $artemisHeaders = StompHeadersEncoder::encode(array_filter($artemisHeaders)); + + foreach ($artemisHeaders as $key => $value) { + $frame[$key] = $value; + } } $this->stomp->sendFrame($frame); } } - /** - * @param Frame $frame - * - * @return StompMessage - */ - private function convertMessage(Frame $frame) + private function convertMessage(Frame $frame): StompMessage { if ('MESSAGE' !== $frame->getCommand()) { throw new \LogicException(sprintf('Frame is not MESSAGE frame but: "%s"', $frame->getCommand())); @@ -212,7 +211,7 @@ private function convertMessage(Frame $frame) list($headers, $properties) = StompHeadersEncoder::decode($frame->getHeaders()); - $redelivered = isset($headers['redelivered']) && $headers['redelivered'] === 'true'; + $redelivered = isset($headers['redelivered']) && 'true' === $headers['redelivered']; unset( $headers['redelivered'], @@ -224,7 +223,7 @@ private function convertMessage(Frame $frame) $headers['content-length'] ); - $message = new StompMessage($frame->getBody(), $properties, $headers); + $message = new StompMessage((string) $frame->getBody(), $properties, $headers); $message->setRedelivered($redelivered); $message->setFrame($frame); diff --git a/pkg/stomp/StompContext.php b/pkg/stomp/StompContext.php index d44071438..1e77f88ee 100644 --- a/pkg/stomp/StompContext.php +++ b/pkg/stomp/StompContext.php @@ -1,10 +1,20 @@ stomp = $stomp; + if ($stomp instanceof BufferedStompClient) { + $this->stomp = $stomp; + } elseif (is_callable($stomp)) { + $this->stompFactory = $stomp; + } else { + throw new \InvalidArgumentException('The stomp argument must be either BufferedStompClient or callable that return BufferedStompClient.'); + } + + $this->extensionType = $extensionType; + $this->useExchangePrefix = ExtensionType::RABBITMQ === $extensionType; + $this->transient = $detectTransientConnections; } /** - * {@inheritdoc} - * * @return StompMessage */ - public function createMessage($body = '', array $properties = [], array $headers = []) + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message { return new StompMessage($body, $properties, $headers); } /** - * {@inheritdoc} - * * @return StompDestination */ - public function createQueue($name) + public function createQueue(string $name): Queue { - if (0 !== strpos($name, '/')) { - $destination = new StompDestination(); + if (!str_starts_with($name, '/')) { + $destination = new StompDestination($this->extensionType); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName($name); @@ -50,11 +86,9 @@ public function createQueue($name) } /** - * {@inheritdoc} - * * @return StompDestination */ - public function createTemporaryQueue() + public function createTemporaryQueue(): Queue { $queue = $this->createQueue(uniqid('', true)); $queue->setType(StompDestination::TYPE_TEMP_QUEUE); @@ -63,15 +97,13 @@ public function createTemporaryQueue() } /** - * {@inheritdoc} - * * @return StompDestination */ - public function createTopic($name) + public function createTopic(string $name): Topic { - if (0 !== strpos($name, '/')) { - $destination = new StompDestination(); - $destination->setType(StompDestination::TYPE_EXCHANGE); + if (!str_starts_with($name, '/')) { + $destination = new StompDestination($this->extensionType); + $destination->setType($this->useExchangePrefix ? StompDestination::TYPE_EXCHANGE : StompDestination::TYPE_TOPIC); $destination->setStompName($name); return $destination; @@ -80,12 +112,7 @@ public function createTopic($name) return $this->createDestination($name); } - /** - * @param string $destination - * - * @return StompDestination - */ - public function createDestination($destination) + public function createDestination(string $destination): StompDestination { $types = [ StompDestination::TYPE_TOPIC, @@ -103,7 +130,7 @@ public function createDestination($destination) foreach ($types as $_type) { $typePrefix = '/'.$_type.'/'; - if (0 === strpos($dest, $typePrefix)) { + if (str_starts_with($dest, $typePrefix)) { $type = $_type; $dest = substr($dest, strlen($typePrefix)); @@ -135,7 +162,7 @@ public function createDestination($destination) $routingKey = $pieces[1]; } - $destination = new StompDestination(); + $destination = new StompDestination($this->extensionType); $destination->setType($type); $destination->setStompName($name); $destination->setRoutingKey($routingKey); @@ -144,34 +171,63 @@ public function createDestination($destination) } /** - * {@inheritdoc} - * * @param StompDestination $destination * * @return StompConsumer */ - public function createConsumer(Destination $destination) + public function createConsumer(Destination $destination): Consumer { InvalidDestinationException::assertDestinationInstanceOf($destination, StompDestination::class); - return new StompConsumer($this->stomp, $destination); + $this->transient = false; + + return new StompConsumer($this->getStomp(), $destination); } /** - * {@inheritdoc} - * * @return StompProducer */ - public function createProducer() + public function createProducer(): Producer { - return new StompProducer($this->stomp); + if ($this->transient && $this->stomp) { + $this->stomp->disconnect(); + } + + return new StompProducer($this->getStomp()); } - /** - * {@inheritdoc} - */ - public function close() + public function close(): void + { + $this->getStomp()->disconnect(); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function purgeQueue(Queue $queue): void { - $this->stomp->disconnect(); + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function getStomp(): BufferedStompClient + { + if (false == $this->stomp) { + $this->stomp = $this->createStomp(); + } + + return $this->stomp; + } + + private function createStomp(): BufferedStompClient + { + $stomp = call_user_func($this->stompFactory); + + if (false == $stomp instanceof BufferedStompClient) { + throw new \LogicException(sprintf('The factory must return instance of BufferedStompClient. It returns %s', is_object($stomp) ? $stomp::class : gettype($stomp))); + } + + return $stomp; } } diff --git a/pkg/stomp/StompDestination.php b/pkg/stomp/StompDestination.php index bf3d84212..3364968c4 100644 --- a/pkg/stomp/StompDestination.php +++ b/pkg/stomp/StompDestination.php @@ -1,22 +1,24 @@ headers = [ self::HEADER_DURABLE => false, self::HEADER_AUTO_DELETE => true, self::HEADER_EXCLUSIVE => false, ]; + + $this->extensionType = $extensionType; } - /** - * @return string - */ - public function getStompName() + public function getExtensionType(): string + { + return $this->extensionType; + } + + public function getStompName(): string { return $this->name; } - /** - * @param string $name - */ - public function setStompName($name) + public function setStompName(string $name): void { $this->name = $name; } - /** - * {@inheritdoc} - */ - public function getQueueName() + public function getQueueName(): string { - if (empty($this->getType()) || empty($this->getStompName())) { - throw new \LogicException('Destination type or name is not set'); + if (empty($this->getStompName())) { + throw new \LogicException('Destination name is not set'); + } + + if (ExtensionType::ARTEMIS === $this->extensionType) { + return $this->getStompName(); } $name = '/'.$this->getType().'/'.$this->getStompName(); @@ -81,26 +89,17 @@ public function getQueueName() return $name; } - /** - * {@inheritdoc} - */ - public function getTopicName() + public function getTopicName(): string { return $this->getQueueName(); } - /** - * @return mixed - */ - public function getType() + public function getType(): string { return $this->type; } - /** - * @param mixed $type - */ - public function setType($type) + public function setType(string $type): void { $types = [ self::TYPE_TOPIC, @@ -118,99 +117,62 @@ public function setType($type) $this->type = $type; } - /** - * @return string - */ - public function getRoutingKey() + public function getRoutingKey(): ?string { return $this->routingKey; } - /** - * @param string $routingKey - */ - public function setRoutingKey($routingKey) + public function setRoutingKey(?string $routingKey = null): void { $this->routingKey = $routingKey; } - /** - * @return bool - */ - public function isDurable() + public function isDurable(): bool { return $this->getHeader(self::HEADER_DURABLE, false); } - /** - * @param bool $durable - */ - public function setDurable($durable) + public function setDurable(bool $durable): void { - $this->setHeader(self::HEADER_DURABLE, (bool) $durable); + $this->setHeader(self::HEADER_DURABLE, $durable); } - /** - * @return bool - */ - public function isAutoDelete() + public function isAutoDelete(): bool { return $this->getHeader(self::HEADER_AUTO_DELETE, false); } - /** - * @param bool $autoDelete - */ - public function setAutoDelete($autoDelete) + public function setAutoDelete(bool $autoDelete): void { - $this->setHeader(self::HEADER_AUTO_DELETE, (bool) $autoDelete); + $this->setHeader(self::HEADER_AUTO_DELETE, $autoDelete); } - /** - * @return bool - */ - public function isExclusive() + public function isExclusive(): bool { return $this->getHeader(self::HEADER_EXCLUSIVE, false); } - /** - * @param bool $exclusive - */ - public function setExclusive($exclusive) + public function setExclusive(bool $exclusive): void { - $this->setHeader(self::HEADER_EXCLUSIVE, (bool) $exclusive); + $this->setHeader(self::HEADER_EXCLUSIVE, $exclusive); } - /** - * @param array $headers - */ - public function setHeaders(array $headers) + public function setHeaders(array $headers): void { $this->headers = $headers; } - /** - * {@inheritdoc} - */ - public function getHeaders() + public function getHeaders(): array { return $this->headers; } - /** - * {@inheritdoc} - */ - public function getHeader($name, $default = null) + public function getHeader(string $name, $default = null) { return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; } - /** - * @param string $name - * @param mixed $value - */ - public function setHeader($name, $value) + public function setHeader(string $name, $value): void { $this->headers[$name] = $value; } diff --git a/pkg/stomp/StompHeadersEncoder.php b/pkg/stomp/StompHeadersEncoder.php index f9a081d21..e3484abf6 100644 --- a/pkg/stomp/StompHeadersEncoder.php +++ b/pkg/stomp/StompHeadersEncoder.php @@ -1,24 +1,20 @@ $value) { - if (0 === strpos($key, self::PROPERTY_PREFIX)) { + if (str_starts_with($key, self::PROPERTY_PREFIX)) { $encodedProperties[substr($key, $prefixLength)] = $value; } else { $encodedHeaders[$key] = $value; @@ -55,12 +49,7 @@ public static function decode(array $headers = []) return [$decodedHeaders, $decodedProperties]; } - /** - * @param array $headers - * - * @return array - */ - private static function doEncode($headers = []) + private static function doEncode(array $headers = []): array { $encoded = []; @@ -99,18 +88,13 @@ private static function doEncode($headers = []) return $encoded; } - /** - * @param array $headers - * - * @return array - */ - private static function doDecode(array $headers = []) + private static function doDecode(array $headers = []): array { $decoded = []; foreach ($headers as $key => $value) { // skip type header - if (0 === strpos($key, self::TYPE_PREFIX)) { + if (str_starts_with($key, self::TYPE_PREFIX)) { continue; } @@ -139,7 +123,7 @@ private static function doDecode(array $headers = []) break; case self::TYPE_BOOL: - $decoded[$key] = $value === 'true'; + $decoded[$key] = 'true' === $value; break; default: diff --git a/pkg/stomp/StompMessage.php b/pkg/stomp/StompMessage.php index ed90f6317..7098679b7 100644 --- a/pkg/stomp/StompMessage.php +++ b/pkg/stomp/StompMessage.php @@ -1,8 +1,10 @@ body = $body; $this->properties = $properties; @@ -45,200 +42,132 @@ public function __construct($body = '', array $properties = [], array $headers = $this->redelivered = false; } - /** - * @param string $body - */ - public function setBody($body) + public function setBody(string $body): void { $this->body = $body; } - /** - * {@inheritdoc} - */ - public function getBody() + public function getBody(): string { return $this->body; } - /** - * @param array $properties - */ - public function setProperties(array $properties) + public function setProperties(array $properties): void { $this->properties = $properties; } - /** - * {@inheritdoc} - */ - public function getProperties() + public function getProperties(): array { return $this->properties; } - /** - * {@inheritdoc} - */ - public function setProperty($name, $value) + public function setProperty(string $name, $value): void { - $this->properties[$name] = $value; + if (null === $value) { + unset($this->properties[$name]); + } else { + $this->properties[$name] = $value; + } } - /** - * {@inheritdoc} - */ - public function getProperty($name, $default = null) + public function getProperty(string $name, $default = null) { return array_key_exists($name, $this->properties) ? $this->properties[$name] : $default; } - /** - * @param array $headers - */ - public function setHeaders(array $headers) + public function setHeaders(array $headers): void { $this->headers = $headers; } - /** - * {@inheritdoc} - */ - public function getHeaders() + public function getHeaders(): array { return $this->headers; } - /** - * {@inheritdoc} - */ - public function setHeader($name, $value) + public function setHeader(string $name, $value): void { - $this->headers[$name] = $value; + if (null === $value) { + unset($this->headers[$name]); + } else { + $this->headers[$name] = $value; + } } - /** - * {@inheritdoc} - */ - public function getHeader($name, $default = null) + public function getHeader(string $name, $default = null) { return array_key_exists($name, $this->headers) ? $this->headers[$name] : $default; } - /** - * note: rabbitmq STOMP protocol extension. - * - * @return bool - */ - public function isPersistent() + public function isPersistent(): bool { return $this->getHeader('persistent', false); } - /** - * note: rabbitmq STOMP protocol extension. - * - * @param bool $persistent - */ - public function setPersistent($persistent) + public function setPersistent(bool $persistent): void { - $this->setHeader('persistent', (bool) $persistent); + $this->setHeader('persistent', $persistent); } - /** - * @return bool - */ - public function isRedelivered() + public function isRedelivered(): bool { return $this->redelivered; } - /** - * @param bool $redelivered - */ - public function setRedelivered($redelivered) + public function setRedelivered(bool $redelivered): void { $this->redelivered = $redelivered; } - /** - * {@inheritdoc} - */ - public function setCorrelationId($correlationId) + public function setCorrelationId(?string $correlationId = null): void { $this->setHeader('correlation_id', (string) $correlationId); } - /** - * {@inheritdoc} - */ - public function getCorrelationId() + public function getCorrelationId(): ?string { - return $this->getHeader('correlation_id', ''); + return $this->getHeader('correlation_id'); } - /** - * {@inheritdoc} - */ - public function setMessageId($messageId) + public function setMessageId(?string $messageId = null): void { $this->setHeader('message_id', (string) $messageId); } - /** - * {@inheritdoc} - */ - public function getMessageId() + public function getMessageId(): ?string { - return $this->getHeader('message_id', ''); + return $this->getHeader('message_id'); } - /** - * {@inheritdoc} - */ - public function getTimestamp() + public function getTimestamp(): ?int { $value = $this->getHeader('timestamp'); - return $value === null ? null : (int) $value; + return null === $value ? null : (int) $value; } - /** - * {@inheritdoc} - */ - public function setTimestamp($timestamp) + public function setTimestamp(?int $timestamp = null): void { $this->setHeader('timestamp', $timestamp); } - /** - * @return Frame - */ - public function getFrame() + public function getFrame(): ?Frame { return $this->frame; } - /** - * @param Frame $frame - */ - public function setFrame(Frame $frame) + public function setFrame(?Frame $frame = null): void { $this->frame = $frame; } - /** - * @param string|null $replyTo - */ - public function setReplyTo($replyTo) + public function setReplyTo(?string $replyTo = null): void { $this->setHeader('reply-to', $replyTo); } - /** - * @return string|null - */ - public function getReplyTo() + public function getReplyTo(): ?string { return $this->getHeader('reply-to'); } diff --git a/pkg/stomp/StompProducer.php b/pkg/stomp/StompProducer.php index 609c5e1de..909720973 100644 --- a/pkg/stomp/StompProducer.php +++ b/pkg/stomp/StompProducer.php @@ -1,12 +1,15 @@ stomp = $stomp; } /** - * {@inheritdoc} - * * @param StompDestination $destination * @param StompMessage $message */ - public function send(Destination $destination, Message $message) + public function send(Destination $destination, Message $message): void { InvalidDestinationException::assertDestinationInstanceOf($destination, StompDestination::class); - InvalidMessageException::assertMessageInstanceOf($message, StompMessage::class); $headers = array_merge($message->getHeaders(), $destination->getHeaders()); @@ -44,4 +41,57 @@ public function send(Destination $destination, Message $message) $this->stomp->send($destination->getQueueName(), $stompMessage); } + + /** + * @return $this|Producer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (empty($deliveryDelay)) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + /** + * @throws PriorityNotSupportedException + * + * @return $this|Producer + */ + public function setPriority(?int $priority = null): Producer + { + if (empty($priority)) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @return $this|Producer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (empty($timeToLive)) { + return $this; + } + + throw new \LogicException('Not implemented'); + } + + public function getTimeToLive(): ?int + { + return null; + } } diff --git a/pkg/stomp/Symfony/RabbitMqStompTransportFactory.php b/pkg/stomp/Symfony/RabbitMqStompTransportFactory.php deleted file mode 100644 index 48bbb719d..000000000 --- a/pkg/stomp/Symfony/RabbitMqStompTransportFactory.php +++ /dev/null @@ -1,74 +0,0 @@ -children() - ->booleanNode('management_plugin_installed') - ->defaultFalse() - ->info('The option tells whether RabbitMQ broker has management plugin installed or not') - ->end() - ->integerNode('management_plugin_port')->min(1)->defaultValue(15672)->end() - ->booleanNode('delay_plugin_installed') - ->defaultFalse() - ->info('The option tells whether RabbitMQ broker has delay plugin installed or not') - ->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $management = new Definition(ManagementClient::class); - $management->setFactory([ManagementClient::class, 'create']); - $management->setArguments([ - $config['vhost'], - $config['host'], - $config['management_plugin_port'], - $config['login'], - $config['password'], - ]); - - $managementId = sprintf('enqueue.client.%s.management_client', $this->getName()); - $container->setDefinition($managementId, $management); - - $driver = new Definition(RabbitMqStompDriver::class); - $driver->setArguments([ - new Reference(sprintf('enqueue.transport.%s.context', $this->getName())), - new Reference('enqueue.client.config'), - new Reference('enqueue.client.meta.queue_meta_registry'), - new Reference($managementId), - ]); - - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $container->setDefinition($driverId, $driver); - - return $driverId; - } -} diff --git a/pkg/stomp/Symfony/StompTransportFactory.php b/pkg/stomp/Symfony/StompTransportFactory.php deleted file mode 100644 index 5c1565e99..000000000 --- a/pkg/stomp/Symfony/StompTransportFactory.php +++ /dev/null @@ -1,91 +0,0 @@ -name = $name; - } - - /** - * {@inheritdoc} - */ - public function addConfiguration(ArrayNodeDefinition $builder) - { - $builder - ->children() - ->scalarNode('host')->defaultValue('localhost')->cannotBeEmpty()->end() - ->integerNode('port')->min(1)->defaultValue(61613)->end() - ->scalarNode('login')->defaultValue('guest')->cannotBeEmpty()->end() - ->scalarNode('password')->defaultValue('guest')->cannotBeEmpty()->end() - ->scalarNode('vhost')->defaultValue('/')->cannotBeEmpty()->end() - ->booleanNode('sync')->defaultTrue()->end() - ->integerNode('connection_timeout')->min(1)->defaultValue(1)->end() - ->integerNode('buffer_size')->min(1)->defaultValue(1000)->end() - ; - } - - /** - * {@inheritdoc} - */ - public function createContext(ContainerBuilder $container, array $config) - { - $factory = new Definition(StompConnectionFactory::class); - $factory->setArguments([$config]); - - $factoryId = sprintf('enqueue.transport.%s.connection_factory', $this->getName()); - $container->setDefinition($factoryId, $factory); - - $context = new Definition(StompContext::class); - $context->setFactory([new Reference($factoryId), 'createContext']); - - $contextId = sprintf('enqueue.transport.%s.context', $this->getName()); - $container->setDefinition($contextId, $context); - - return $contextId; - } - - /** - * {@inheritdoc} - */ - public function createDriver(ContainerBuilder $container, array $config) - { - $driver = new Definition(StompDriver::class); - $driver->setArguments([ - new Reference(sprintf('enqueue.transport.%s.context', $this->getName())), - new Reference('enqueue.client.config'), - ]); - - $driverId = sprintf('enqueue.client.%s.driver', $this->getName()); - $container->setDefinition($driverId, $driver); - - return $driverId; - } - - /** - * {@inheritdoc} - */ - public function getName() - { - return $this->name; - } -} diff --git a/pkg/stomp/Tests/BufferedStompClientTest.php b/pkg/stomp/Tests/BufferedStompClientTest.php index 11d4cc3cf..e4b6226e1 100644 --- a/pkg/stomp/Tests/BufferedStompClientTest.php +++ b/pkg/stomp/Tests/BufferedStompClientTest.php @@ -4,14 +4,16 @@ use Enqueue\Stomp\BufferedStompClient; use Enqueue\Test\ClassExtensionTrait; +use Enqueue\Test\ReadAttributeTrait; use Enqueue\Test\WriteAttributeTrait; use Stomp\Client; use Stomp\Network\Connection; use Stomp\Transport\Frame; -class BufferedStompClientTest extends \PHPUnit_Framework_TestCase +class BufferedStompClientTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; + use ReadAttributeTrait; use WriteAttributeTrait; public function testShouldExtendLibStompClient() @@ -19,11 +21,6 @@ public function testShouldExtendLibStompClient() $this->assertClassExtends(Client::class, BufferedStompClient::class); } - public function testCouldBeConstructedWithRequiredArguments() - { - new BufferedStompClient('tcp://localhost:12345'); - } - public function testCouldGetBufferSizeValue() { $client = new BufferedStompClient('tcp://localhost:12345', 12345); @@ -179,7 +176,7 @@ public function testShouldAddFirstFrameToBufferIfSubscriptionNotEqualAndReturnSe } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Connection + * @return \PHPUnit\Framework\MockObject\MockObject|Connection */ private function createStompConnectionMock() { diff --git a/pkg/stomp/Tests/Client/ManagementClientTest.php b/pkg/stomp/Tests/Client/ManagementClientTest.php deleted file mode 100644 index 598f5d051..000000000 --- a/pkg/stomp/Tests/Client/ManagementClientTest.php +++ /dev/null @@ -1,111 +0,0 @@ -createExchangeMock(); - $exchange - ->expects($this->once()) - ->method('create') - ->with('vhost', 'name', ['options']) - ; - - $client = $this->createClientMock(); - $client - ->expects($this->once()) - ->method('exchanges') - ->willReturn($exchange) - ; - - $management = new ManagementClient($client, 'vhost'); - $management->declareExchange('name', ['options']); - } - - public function testCouldDeclareQueue() - { - $queue = $this->createQueueMock(); - $queue - ->expects($this->once()) - ->method('create') - ->with('vhost', 'name', ['options']) - ; - - $client = $this->createClientMock(); - $client - ->expects($this->once()) - ->method('queues') - ->willReturn($queue) - ; - - $management = new ManagementClient($client, 'vhost'); - $management->declareQueue('name', ['options']); - } - - public function testCouldBind() - { - $binding = $this->createBindingMock(); - $binding - ->expects($this->once()) - ->method('create') - ->with('vhost', 'exchange', 'queue', 'routing-key', ['arguments']) - ; - - $client = $this->createClientMock(); - $client - ->expects($this->once()) - ->method('bindings') - ->willReturn($binding) - ; - - $management = new ManagementClient($client, 'vhost'); - $management->bind('exchange', 'queue', 'routing-key', ['arguments']); - } - - public function testCouldCreateNewInstanceUsingFactory() - { - $instance = ManagementClient::create('', ''); - - $this->assertInstanceOf(ManagementClient::class, $instance); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Client - */ - private function createClientMock() - { - return $this->createMock(Client::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Exchange - */ - private function createExchangeMock() - { - return $this->createMock(Exchange::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Queue - */ - private function createQueueMock() - { - return $this->createMock(Queue::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|Binding - */ - private function createBindingMock() - { - return $this->createMock(Binding::class); - } -} diff --git a/pkg/stomp/Tests/Client/RabbitMqStompDriverTest.php b/pkg/stomp/Tests/Client/RabbitMqStompDriverTest.php deleted file mode 100644 index 387ddf896..000000000 --- a/pkg/stomp/Tests/Client/RabbitMqStompDriverTest.php +++ /dev/null @@ -1,641 +0,0 @@ -assertClassImplements(DriverInterface::class, RabbitMqStompDriver::class); - } - - public function testCouldBeConstructedWithRequiredArguments() - { - new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - } - - public function testShouldReturnConfigObject() - { - $config = new Config('', '', '', '', '', ''); - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - $config, - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->assertSame($config, $driver->getConfig()); - } - - public function testShouldCreateAndReturnQueueInstance() - { - $expectedQueue = new StompDestination(); - - $session = $this->createPsrContextMock(); - $session - ->expects($this->once()) - ->method('createQueue') - ->with('name') - ->will($this->returnValue($expectedQueue)) - ; - - $driver = new RabbitMqStompDriver( - $session, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $queue = $driver->createQueue('name'); - - $expectedHeaders = [ - 'durable' => true, - 'auto-delete' => false, - 'exclusive' => false, - 'x-max-priority' => 4, - ]; - - $this->assertSame($expectedQueue, $queue); - $this->assertTrue($queue->isDurable()); - $this->assertFalse($queue->isAutoDelete()); - $this->assertFalse($queue->isExclusive()); - $this->assertSame($expectedHeaders, $queue->getHeaders()); - } - - public function testShouldConvertTransportMessageToClientMessage() - { - $transportMessage = new StompMessage(); - $transportMessage->setBody('body'); - $transportMessage->setHeaders(['hkey' => 'hval']); - $transportMessage->setProperties(['key' => 'val']); - $transportMessage->setHeader('content-type', 'ContentType'); - $transportMessage->setHeader('expiration', '12345000'); - $transportMessage->setHeader('priority', 3); - $transportMessage->setHeader('x-delay', '5678000'); - $transportMessage->setMessageId('MessageId'); - $transportMessage->setTimestamp(1000); - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $clientMessage = $driver->createClientMessage($transportMessage); - - $this->assertInstanceOf(Message::class, $clientMessage); - $this->assertSame('body', $clientMessage->getBody()); - $this->assertSame(['hkey' => 'hval'], $clientMessage->getHeaders()); - $this->assertSame(['key' => 'val'], $clientMessage->getProperties()); - $this->assertSame('MessageId', $clientMessage->getMessageId()); - $this->assertSame(12345, $clientMessage->getExpire()); - $this->assertSame(5678, $clientMessage->getDelay()); - $this->assertSame('ContentType', $clientMessage->getContentType()); - $this->assertSame(1000, $clientMessage->getTimestamp()); - $this->assertSame(MessagePriority::HIGH, $clientMessage->getPriority()); - } - - public function testShouldThrowExceptionIfXDelayIsNotNumeric() - { - $transportMessage = new StompMessage(); - $transportMessage->setHeader('x-delay', 'is-not-numeric'); - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('x-delay header is not numeric. "is-not-numeric"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfExpirationIsNotNumeric() - { - $transportMessage = new StompMessage(); - $transportMessage->setHeader('expiration', 'is-not-numeric'); - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('expiration header is not numeric. "is-not-numeric"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertTransportPriorityToClientPriority() - { - $transportMessage = new StompMessage(); - $transportMessage->setHeader('priority', 'unknown'); - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Cant convert transport priority to client: "unknown"'); - - $driver->createClientMessage($transportMessage); - } - - public function testShouldThrowExceptionIfCantConvertClientPriorityToTransportPriority() - { - $clientMessage = new Message(); - $clientMessage->setPriority('unknown'); - - $transportMessage = new StompMessage(); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Cant convert client priority to transport: "unknown"'); - - $driver->createTransportMessage($clientMessage); - } - - public function testShouldConvertClientMessageToTransportMessage() - { - $clientMessage = new Message(); - $clientMessage->setBody('body'); - $clientMessage->setHeaders(['hkey' => 'hval']); - $clientMessage->setProperties(['key' => 'val']); - $clientMessage->setContentType('ContentType'); - $clientMessage->setExpire(123); - $clientMessage->setPriority(MessagePriority::VERY_HIGH); - $clientMessage->setDelay(432); - $clientMessage->setMessageId('MessageId'); - $clientMessage->setTimestamp(1000); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new StompMessage()) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $transportMessage = $driver->createTransportMessage($clientMessage); - - $this->assertInstanceOf(StompMessage::class, $transportMessage); - $this->assertSame('body', $transportMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content-type' => 'ContentType', - 'persistent' => true, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - 'expiration' => '123000', - 'priority' => 4, - 'x-delay' => '432000', - ], $transportMessage->getHeaders()); - $this->assertSame(['key' => 'val'], $transportMessage->getProperties()); - $this->assertSame('MessageId', $transportMessage->getMessageId()); - $this->assertSame(1000, $transportMessage->getTimestamp()); - } - - public function testShouldThrowExceptionIfDelayIsSetButDelayPluginInstalledOptionIsFalse() - { - $clientMessage = new Message(); - $clientMessage->setDelay(123); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new StompMessage()) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => false]), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('The message delaying is not supported. In order to use delay feature install RabbitMQ delay plugin.'); - - $driver->createTransportMessage($clientMessage); - } - - public function testShouldSendMessageToRouter() - { - $topic = new StompDestination(''); - $transportMessage = new StompMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($topic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($topic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_TOPIC_NAME, 'topic'); - - $driver->sendToRouter($message); - } - - public function testShouldThrowExceptionIfTopicParameterIsNotSet() - { - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name parameter is required but is not set'); - - $driver->sendToRouter(new Message()); - } - - public function testShouldSendMessageToProcessor() - { - $queue = new StompDestination(''); - $transportMessage = new StompMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - - $driver->sendToProcessor($message); - } - - public function testShouldSendMessageToDelayExchangeIfDelaySet() - { - $queue = new StompDestination(''); - $delayTopic = new StompDestination(''); - $transportMessage = new StompMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($delayTopic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($delayTopic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new RabbitMqStompDriver( - $context, - new Config('', '', '', '', '', '', ['delay_plugin_installed' => true]), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - $message->setDelay(10); - - $driver->sendToProcessor($message); - } - - public function testShouldThrowExceptionIfProcessorNameParameterIsNotSet() - { - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Processor name parameter is required but is not set'); - - $driver->sendToProcessor(new Message()); - } - - public function testShouldThrowExceptionIfProcessorQueueNameParameterIsNotSet() - { - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', ''), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Queue name parameter is required but is not set'); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - - $driver->sendToProcessor($message); - } - - public function testShouldNotSetupBrokerIfManagementPluginInstalledOptionIsNotEnabled() - { - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '', ['management_plugin_installed' => false]), - $this->createQueueMetaRegistryMock(), - $this->createManagementClientMock() - ); - - $logger = $this->createLoggerMock(); - $logger - ->expects($this->once()) - ->method('debug') - ->with('[RabbitMqStompDriver] Could not setup broker. The option `management_plugin_installed` is not enabled. Please enable that option and install rabbit management plugin') - ; - - $driver->setupBroker($logger); - } - - public function testShouldSetupBroker() - { - $metaRegistry = $this->createQueueMetaRegistryMock(); - $metaRegistry - ->expects($this->once()) - ->method('getQueuesMeta') - ->willReturn([new QueueMeta('processorQueue', '')]) - ; - - $managementClient = $this->createManagementClientMock(); - $managementClient - ->expects($this->at(0)) - ->method('declareExchange') - ->with('prefix.routertopic', [ - 'type' => 'fanout', - 'durable' => true, - 'auto_delete' => false, - ]) - ; - $managementClient - ->expects($this->at(1)) - ->method('declareQueue') - ->with('prefix.app.routerqueue', [ - 'durable' => true, - 'auto_delete' => false, - 'arguments' => [ - 'x-max-priority' => 4, - ], - ]) - ; - $managementClient - ->expects($this->at(2)) - ->method('bind') - ->with('prefix.routertopic', 'prefix.app.routerqueue', 'prefix.app.routerqueue') - ; - $managementClient - ->expects($this->at(3)) - ->method('declareQueue') - ->with('prefix.app.processorqueue', [ - 'durable' => true, - 'auto_delete' => false, - 'arguments' => [ - 'x-max-priority' => 4, - ], - ]) - ; - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('prefix', 'app', 'routerTopic', 'routerQueue', 'processorQueue', '', ['management_plugin_installed' => true]), - $metaRegistry, - $managementClient - ); - - $logger = $this->createLoggerMock(); - $logger - ->expects($this->at(0)) - ->method('debug') - ->with('[RabbitMqStompDriver] Declare router exchange: prefix.routertopic') - ; - $logger - ->expects($this->at(1)) - ->method('debug') - ->with('[RabbitMqStompDriver] Declare router queue: prefix.app.routerqueue') - ; - $logger - ->expects($this->at(2)) - ->method('debug') - ->with('[RabbitMqStompDriver] Bind router queue to exchange: prefix.app.routerqueue -> prefix.routertopic') - ; - $logger - ->expects($this->at(3)) - ->method('debug') - ->with('[RabbitMqStompDriver] Declare processor queue: prefix.app.processorqueue') - ; - - $driver->setupBroker($logger); - } - - public function testSetupBrokerShouldCreateDelayExchangeIfEnabled() - { - $metaRegistry = $this->createQueueMetaRegistryMock(); - $metaRegistry - ->expects($this->exactly(2)) - ->method('getQueuesMeta') - ->willReturn([new QueueMeta('processorQueue', '')]) - ; - - $managementClient = $this->createManagementClientMock(); - $managementClient - ->expects($this->at(4)) - ->method('declareExchange') - ->with('prefix.app.processorqueue.delayed', [ - 'type' => 'x-delayed-message', - 'durable' => true, - 'auto_delete' => false, - 'arguments' => [ - 'x-delayed-type' => 'direct', - ], - ]) - ; - $managementClient - ->expects($this->at(5)) - ->method('bind') - ->with('prefix.app.processorqueue.delayed', 'prefix.app.processorqueue', 'prefix.app.processorqueue') - ; - - $driver = new RabbitMqStompDriver( - $this->createPsrContextMock(), - new Config('prefix', 'app', 'routerTopic', 'routerQueue', 'processorQueue', '', ['management_plugin_installed' => true, 'delay_plugin_installed' => true]), - $metaRegistry, - $managementClient - ); - - $logger = $this->createLoggerMock(); - $logger - ->expects($this->at(4)) - ->method('debug') - ->with('[RabbitMqStompDriver] Declare delay exchange: prefix.app.processorqueue.delayed') - ; - $logger - ->expects($this->at(5)) - ->method('debug') - ->with('[RabbitMqStompDriver] Bind processor queue to delay exchange: prefix.app.processorqueue -> prefix.app.processorqueue.delayed') - ; - - $driver->setupBroker($logger); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|StompContext - */ - private function createPsrContextMock() - { - return $this->createMock(StompContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|StompProducer - */ - private function createPsrProducerMock() - { - return $this->createMock(StompProducer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|QueueMetaRegistry - */ - private function createQueueMetaRegistryMock() - { - return $this->createMock(QueueMetaRegistry::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ManagementClient - */ - private function createManagementClientMock() - { - return $this->createMock(ManagementClient::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface - */ - private function createLoggerMock() - { - return $this->createMock(LoggerInterface::class); - } -} diff --git a/pkg/stomp/Tests/Client/StompDriverTest.php b/pkg/stomp/Tests/Client/StompDriverTest.php deleted file mode 100644 index 5eeee2971..000000000 --- a/pkg/stomp/Tests/Client/StompDriverTest.php +++ /dev/null @@ -1,285 +0,0 @@ -assertClassImplements(DriverInterface::class, StompDriver::class); - } - - public function testCouldBeConstructedWithRequiredArguments() - { - new StompDriver($this->createPsrContextMock(), new Config('', '', '', '', '', '')); - } - - public function testShouldReturnConfigObject() - { - $config = new Config('', '', '', '', '', ''); - - $driver = new StompDriver($this->createPsrContextMock(), $config); - - $this->assertSame($config, $driver->getConfig()); - } - - public function testShouldCreateAndReturnQueueInstance() - { - $expectedQueue = new StompDestination(); - - $session = $this->createPsrContextMock(); - $session - ->expects($this->once()) - ->method('createQueue') - ->with('name') - ->will($this->returnValue($expectedQueue)) - ; - - $driver = new StompDriver($session, new Config('', '', '', '', '', '')); - - $queue = $driver->createQueue('name'); - - $this->assertSame($expectedQueue, $queue); - $this->assertTrue($queue->isDurable()); - $this->assertFalse($queue->isAutoDelete()); - $this->assertFalse($queue->isExclusive()); - $this->assertSame([ - 'durable' => true, - 'auto-delete' => false, - 'exclusive' => false, - ], $queue->getHeaders()); - } - - public function testShouldConvertTransportMessageToClientMessage() - { - $transportMessage = new StompMessage(); - $transportMessage->setBody('body'); - $transportMessage->setHeaders(['hkey' => 'hval']); - $transportMessage->setProperties(['key' => 'val']); - $transportMessage->setHeader('content-type', 'ContentType'); - $transportMessage->setMessageId('MessageId'); - $transportMessage->setTimestamp(1000); - - $driver = new StompDriver($this->createPsrContextMock(), new Config('', '', '', '', '', '')); - - $clientMessage = $driver->createClientMessage($transportMessage); - - $this->assertInstanceOf(Message::class, $clientMessage); - $this->assertSame('body', $clientMessage->getBody()); - $this->assertSame(['hkey' => 'hval'], $clientMessage->getHeaders()); - $this->assertSame(['key' => 'val'], $clientMessage->getProperties()); - $this->assertSame('MessageId', $clientMessage->getMessageId()); - $this->assertSame('ContentType', $clientMessage->getContentType()); - $this->assertSame(1000, $clientMessage->getTimestamp()); - } - - public function testShouldConvertClientMessageToTransportMessage() - { - $clientMessage = new Message(); - $clientMessage->setBody('body'); - $clientMessage->setHeaders(['hkey' => 'hval']); - $clientMessage->setProperties(['key' => 'val']); - $clientMessage->setContentType('ContentType'); - $clientMessage->setMessageId('MessageId'); - $clientMessage->setTimestamp(1000); - - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn(new StompMessage()) - ; - - $driver = new StompDriver($context, new Config('', '', '', '', '', '')); - - $transportMessage = $driver->createTransportMessage($clientMessage); - - $this->assertInstanceOf(StompMessage::class, $transportMessage); - $this->assertSame('body', $transportMessage->getBody()); - $this->assertSame([ - 'hkey' => 'hval', - 'content-type' => 'ContentType', - 'persistent' => true, - 'message_id' => 'MessageId', - 'timestamp' => 1000, - ], $transportMessage->getHeaders()); - $this->assertSame(['key' => 'val'], $transportMessage->getProperties()); - $this->assertSame('MessageId', $transportMessage->getMessageId()); - $this->assertSame(1000, $transportMessage->getTimestamp()); - } - - public function testShouldSendMessageToRouter() - { - $topic = new StompDestination(''); - $transportMessage = new StompMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($topic), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createTopic') - ->willReturn($topic) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new StompDriver( - $context, - new Config('', '', '', '', '', '') - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_TOPIC_NAME, 'topic'); - - $driver->sendToRouter($message); - } - - public function testShouldThrowExceptionIfTopicParameterIsNotSet() - { - $driver = new StompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '') - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Topic name parameter is required but is not set'); - - $driver->sendToRouter(new Message()); - } - - public function testShouldSendMessageToProcessor() - { - $queue = new StompDestination(''); - $transportMessage = new StompMessage(); - - $producer = $this->createPsrProducerMock(); - $producer - ->expects($this->once()) - ->method('send') - ->with($this->identicalTo($queue), $this->identicalTo($transportMessage)) - ; - $context = $this->createPsrContextMock(); - $context - ->expects($this->once()) - ->method('createQueue') - ->willReturn($queue) - ; - $context - ->expects($this->once()) - ->method('createProducer') - ->willReturn($producer) - ; - $context - ->expects($this->once()) - ->method('createMessage') - ->willReturn($transportMessage) - ; - - $driver = new StompDriver( - $context, - new Config('', '', '', '', '', '') - ); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - $message->setProperty(Config::PARAMETER_PROCESSOR_QUEUE_NAME, 'queue'); - - $driver->sendToProcessor($message); - } - - public function testShouldThrowExceptionIfProcessorNameParameterIsNotSet() - { - $driver = new StompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '') - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Processor name parameter is required but is not set'); - - $driver->sendToProcessor(new Message()); - } - - public function testShouldThrowExceptionIfProcessorQueueNameParameterIsNotSet() - { - $driver = new StompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '') - ); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Queue name parameter is required but is not set'); - - $message = new Message(); - $message->setProperty(Config::PARAMETER_PROCESSOR_NAME, 'processor'); - - $driver->sendToProcessor($message); - } - - public function testSetupBrokerShouldOnlyLogMessageThatStompDoesNotSupprtBrokerSetup() - { - $driver = new StompDriver( - $this->createPsrContextMock(), - new Config('', '', '', '', '', '') - ); - - $logger = $this->createLoggerMock(); - $logger - ->expects($this->once()) - ->method('debug') - ->with('[StompDriver] Stomp protocol does not support broker configuration') - ; - - $driver->setupBroker($logger); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|StompContext - */ - private function createPsrContextMock() - { - return $this->createMock(StompContext::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|StompProducer - */ - private function createPsrProducerMock() - { - return $this->createMock(StompProducer::class); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerInterface - */ - private function createLoggerMock() - { - return $this->createMock(LoggerInterface::class); - } -} diff --git a/pkg/stomp/Tests/Functional/StompCommonUseCasesTest.php b/pkg/stomp/Tests/Functional/StompCommonUseCasesTest.php index 18061d297..e3f09737a 100644 --- a/pkg/stomp/Tests/Functional/StompCommonUseCasesTest.php +++ b/pkg/stomp/Tests/Functional/StompCommonUseCasesTest.php @@ -4,30 +4,30 @@ use Enqueue\Stomp\StompContext; use Enqueue\Stomp\StompMessage; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqStompExtension; /** * @group functional */ -class StompCommonUseCasesTest extends \PHPUnit_Framework_TestCase +class StompCommonUseCasesTest extends \PHPUnit\Framework\TestCase { + use RabbitManagementExtensionTrait; use RabbitmqStompExtension; - use RabbitmqManagmentExtensionTrait; /** * @var StompContext */ private $stompContext; - public function setUp() + protected function setUp(): void { $this->stompContext = $this->buildStompContext(); $this->removeQueue('stomp.test'); } - public function tearDown() + protected function tearDown(): void { $this->stompContext->close(); } @@ -41,14 +41,14 @@ public function testWaitsForTwoSecondsAndReturnNullOnReceive() $startAt = microtime(true); $consumer = $this->stompContext->createConsumer($queue); - $message = $consumer->receive(2); + $message = $consumer->receive(2000); $endAt = microtime(true); $this->assertNull($message); $this->assertGreaterThan(1.5, $endAt - $startAt); - $this->assertLessThan(2.5, $endAt - $startAt); + $this->assertLessThan(3, $endAt - $startAt); } public function testReturnNullImmediatelyOnReceiveNoWait() @@ -66,7 +66,7 @@ public function testReturnNullImmediatelyOnReceiveNoWait() $this->assertNull($message); - $this->assertLessThan(0.5, $endAt - $startAt); + $this->assertLessThan(1, $endAt - $startAt); } public function testProduceAndReceiveOneMessage() @@ -85,7 +85,7 @@ public function testProduceAndReceiveOneMessage() $producer->send($queue, $message); $consumer = $this->stompContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(1000); $this->assertInstanceOf(StompMessage::class, $message); $consumer->acknowledge($message); diff --git a/pkg/stomp/Tests/Functional/StompConnectionFactoryTest.php b/pkg/stomp/Tests/Functional/StompConnectionFactoryTest.php new file mode 100644 index 000000000..6d4223616 --- /dev/null +++ b/pkg/stomp/Tests/Functional/StompConnectionFactoryTest.php @@ -0,0 +1,51 @@ +getDsn().'?send_heartbeat=2000'; + $factory = new StompConnectionFactory($dsn); + $this->expectException(HeartbeatException::class); + $factory->createContext()->getStomp(); + } + + public function testShouldCreateConnectionWithSendHeartbeat() + { + $dsn = $this->getDsn().'?send_heartbeat=2000&read_timeout=1'; + $factory = new StompConnectionFactory($dsn); + $context = $factory->createContext(); + + $observers = $context->getStomp()->getConnection()->getObservers()->getObservers(); + $this->assertAttributeEquals([2000, 0], 'heartbeat', $context->getStomp()); + $this->assertCount(1, $observers); + $this->assertInstanceOf(HeartbeatEmitter::class, $observers[0]); + } + + public function testShouldCreateConnectionWithReceiveHeartbeat() + { + $dsn = $this->getDsn().'?receive_heartbeat=2000'; + $factory = new StompConnectionFactory($dsn); + $context = $factory->createContext(); + + $observers = $context->getStomp()->getConnection()->getObservers()->getObservers(); + $this->assertAttributeEquals([0, 2000], 'heartbeat', $context->getStomp()); + $this->assertCount(1, $observers); + $this->assertInstanceOf(ServerAliveObserver::class, $observers[0]); + } +} diff --git a/pkg/stomp/Tests/Functional/StompConsumptionUseCasesTest.php b/pkg/stomp/Tests/Functional/StompConsumptionUseCasesTest.php index 15f50010e..2025380fd 100644 --- a/pkg/stomp/Tests/Functional/StompConsumptionUseCasesTest.php +++ b/pkg/stomp/Tests/Functional/StompConsumptionUseCasesTest.php @@ -8,34 +8,34 @@ use Enqueue\Consumption\Extension\ReplyExtension; use Enqueue\Consumption\QueueConsumer; use Enqueue\Consumption\Result; -use Enqueue\Psr\Context; -use Enqueue\Psr\Message; -use Enqueue\Psr\Processor; use Enqueue\Stomp\StompContext; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqStompExtension; +use Interop\Queue\Context; +use Interop\Queue\Message; +use Interop\Queue\Processor; /** * @group functional */ -class StompConsumptionUseCasesTest extends \PHPUnit_Framework_TestCase +class StompConsumptionUseCasesTest extends \PHPUnit\Framework\TestCase { + use RabbitManagementExtensionTrait; use RabbitmqStompExtension; - use RabbitmqManagmentExtensionTrait; /** * @var StompContext */ private $stompContext; - public function setUp() + protected function setUp(): void { $this->stompContext = $this->buildStompContext(); $this->removeQueue('stomp.test'); } - public function tearDown() + protected function tearDown(): void { $this->stompContext->close(); } @@ -98,7 +98,7 @@ public function testConsumeOneMessageAndSendReplyExit() class StubProcessor implements Processor { - public $result = Result::ACK; + public $result = self::ACK; /** @var Message */ public $lastProcessedMessage; diff --git a/pkg/stomp/Tests/Functional/StompRpcUseCasesTest.php b/pkg/stomp/Tests/Functional/StompRpcUseCasesTest.php index ec1ad55a5..4cbb3af47 100644 --- a/pkg/stomp/Tests/Functional/StompRpcUseCasesTest.php +++ b/pkg/stomp/Tests/Functional/StompRpcUseCasesTest.php @@ -6,23 +6,23 @@ use Enqueue\Rpc\RpcClient; use Enqueue\Stomp\StompContext; use Enqueue\Stomp\StompMessage; -use Enqueue\Test\RabbitmqManagmentExtensionTrait; +use Enqueue\Test\RabbitManagementExtensionTrait; use Enqueue\Test\RabbitmqStompExtension; /** * @group functional */ -class StompRpcUseCasesTest extends \PHPUnit_Framework_TestCase +class StompRpcUseCasesTest extends \PHPUnit\Framework\TestCase { + use RabbitManagementExtensionTrait; use RabbitmqStompExtension; - use RabbitmqManagmentExtensionTrait; /** * @var StompContext */ private $stompContext; - public function setUp() + protected function setUp(): void { $this->stompContext = $this->buildStompContext(); @@ -30,7 +30,7 @@ public function setUp() $this->removeQueue('stomp.rpc.reply_test'); } - public function tearDown() + protected function tearDown(): void { $this->stompContext->close(); } @@ -47,11 +47,11 @@ public function testDoAsyncRpcCallWithCustomReplyQueue() $message = $this->stompContext->createMessage(); $message->setReplyTo($replyQueue->getQueueName()); - $promise = $rpcClient->callAsync($queue, $message, 10); + $promise = $rpcClient->callAsync($queue, $message, 200); $this->assertInstanceOf(Promise::class, $promise); $consumer = $this->stompContext->createConsumer($queue); - $message = $consumer->receive(1); + $message = $consumer->receive(100); $this->assertInstanceOf(StompMessage::class, $message); $this->assertNotNull($message->getReplyTo()); $this->assertNotNull($message->getCorrelationId()); @@ -63,7 +63,7 @@ public function testDoAsyncRpcCallWithCustomReplyQueue() $this->stompContext->createProducer()->send($replyQueue, $replyMessage); - $actualReplyMessage = $promise->getMessage(); + $actualReplyMessage = $promise->receive(); $this->assertInstanceOf(StompMessage::class, $actualReplyMessage); } @@ -77,11 +77,11 @@ public function testDoAsyncRecCallWithCastInternallyCreatedTemporaryReplyQueue() $message = $this->stompContext->createMessage(); - $promise = $rpcClient->callAsync($queue, $message, 10); + $promise = $rpcClient->callAsync($queue, $message, 200); $this->assertInstanceOf(Promise::class, $promise); $consumer = $this->stompContext->createConsumer($queue); - $receivedMessage = $consumer->receive(1); + $receivedMessage = $consumer->receive(100); $this->assertInstanceOf(StompMessage::class, $receivedMessage); $this->assertNotNull($receivedMessage->getReplyTo()); @@ -94,7 +94,7 @@ public function testDoAsyncRecCallWithCastInternallyCreatedTemporaryReplyQueue() $this->stompContext->createProducer()->send($replyQueue, $replyMessage); - $actualReplyMessage = $promise->getMessage(); + $actualReplyMessage = $promise->receive(); $this->assertInstanceOf(StompMessage::class, $actualReplyMessage); } } diff --git a/pkg/stomp/Tests/Spec/StompMessageTest.php b/pkg/stomp/Tests/Spec/StompMessageTest.php new file mode 100644 index 000000000..8f6748b63 --- /dev/null +++ b/pkg/stomp/Tests/Spec/StompMessageTest.php @@ -0,0 +1,14 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string or null'); + + new StompConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotStomp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given DSN is not supported. Must start with "stomp:".'); + + new StompConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new StompConnectionFactory('foo'); + } + + /** + * @dataProvider provideConfigs + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new StompConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 61613, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + 'stomp:', + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 61613, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + [], + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 61613, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + 'stomp://localhost:1234?foo=bar&lazy=0&sync=true', + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 1234, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => true, + 'lazy' => false, + 'foo' => 'bar', + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + 'stomp+activemq://localhost:1234?foo=bar&lazy=0&sync=true', + [ + 'target' => 'activemq', + 'host' => 'localhost', + 'port' => 1234, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => true, + 'lazy' => false, + 'foo' => 'bar', + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + 'stomp+rabbitmq://localhost:1234?foo=bar&lazy=0&sync=true', + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 1234, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => true, + 'lazy' => false, + 'foo' => 'bar', + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + ['dsn' => 'stomp://localhost:1234/theVhost?foo=bar&lazy=0&sync=true', 'baz' => 'bazVal', 'foo' => 'fooVal'], + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 1234, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => 'theVhost', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => true, + 'lazy' => false, + 'foo' => 'bar', + 'ssl_on' => false, + 'baz' => 'bazVal', + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + ['dsn' => 'stomp:///%2f'], + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 61613, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + + yield [ + ['host' => 'localhost', 'port' => 1234, 'foo' => 'bar'], + [ + 'target' => 'rabbitmq', + 'host' => 'localhost', + 'port' => 1234, + 'login' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + 'buffer_size' => 1000, + 'connection_timeout' => 1, + 'sync' => false, + 'lazy' => true, + 'foo' => 'bar', + 'ssl_on' => false, + 'write_timeout' => 3, + 'read_timeout' => 60, + 'send_heartbeat' => 0, + 'receive_heartbeat' => 0, + 'detect_transient_connections' => false, + ], + ]; + } +} diff --git a/pkg/stomp/Tests/StompConnectionFactoryTest.php b/pkg/stomp/Tests/StompConnectionFactoryTest.php new file mode 100644 index 000000000..1f39e3b20 --- /dev/null +++ b/pkg/stomp/Tests/StompConnectionFactoryTest.php @@ -0,0 +1,57 @@ +assertClassImplements(ConnectionFactory::class, StompConnectionFactory::class); + } + + public function testShouldCreateLazyContext() + { + $factory = new StompConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(StompContext::class, $context); + + $this->assertAttributeEquals(null, 'stomp', $context); + $this->assertAttributeEquals(true, 'useExchangePrefix', $context); + self::assertIsCallable($this->readAttribute($context, 'stompFactory')); + } + + public function testShouldCreateRabbitMQContext() + { + $factory = new StompConnectionFactory('stomp+rabbitmq://'); + + $context = $factory->createContext(); + + $this->assertInstanceOf(StompContext::class, $context); + + $this->assertAttributeEquals(null, 'stomp', $context); + $this->assertAttributeEquals(true, 'useExchangePrefix', $context); + } + + public function testShouldCreateActiveMQContext() + { + $factory = new StompConnectionFactory('stomp+activemq://'); + + $context = $factory->createContext(); + + $this->assertInstanceOf(StompContext::class, $context); + + $this->assertAttributeEquals(null, 'stomp', $context); + $this->assertAttributeEquals(false, 'useExchangePrefix', $context); + } +} diff --git a/pkg/stomp/Tests/StompConsumerTest.php b/pkg/stomp/Tests/StompConsumerTest.php index 4a7f71727..d461284c9 100644 --- a/pkg/stomp/Tests/StompConsumerTest.php +++ b/pkg/stomp/Tests/StompConsumerTest.php @@ -2,48 +2,46 @@ namespace Enqueue\Stomp\Tests; -use Enqueue\Psr\Consumer; -use Enqueue\Psr\InvalidMessageException; -use Enqueue\Psr\Message; use Enqueue\Stomp\BufferedStompClient; +use Enqueue\Stomp\ExtensionType; use Enqueue\Stomp\StompConsumer; use Enqueue\Stomp\StompDestination; use Enqueue\Stomp\StompMessage; use Enqueue\Test\ClassExtensionTrait; +use Enqueue\Test\ReadAttributeTrait; +use Interop\Queue\Consumer; +use Interop\Queue\Exception\InvalidMessageException; +use Interop\Queue\Message; use Stomp\Protocol\Protocol; use Stomp\Transport\Frame; -class StompConsumerTest extends \PHPUnit_Framework_TestCase +class StompConsumerTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; + use ReadAttributeTrait; public function testShouldImplementMessageConsumerInterface() { $this->assertClassImplements(Consumer::class, StompConsumer::class); } - public function testCouldBeConstructedWithRequiredAttributes() - { - new StompConsumer($this->createStompClientMock(), new StompDestination()); - } - public function testCouldGetQueue() { - $consumer = new StompConsumer($this->createStompClientMock(), $dest = new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $dest = $this->createDummyDestination()); $this->assertSame($dest, $consumer->getQueue()); } public function testShouldReturnDefaultAckMode() { - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $this->assertSame(StompConsumer::ACK_CLIENT_INDIVIDUAL, $consumer->getAckMode()); } public function testCouldSetGetAckMethod() { - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $consumer->setAckMode(StompConsumer::ACK_CLIENT); $this->assertSame(StompConsumer::ACK_CLIENT, $consumer->getAckMode()); @@ -54,20 +52,20 @@ public function testShouldThrowLogicExceptionIfAckModeIsInvalid() $this->expectException(\LogicException::class); $this->expectExceptionMessage('Ack mode is not valid: "invalid-ack-mode"'); - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $consumer->setAckMode('invalid-ack-mode'); } public function testShouldReturnDefaultPrefetchCount() { - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $this->assertSame(1, $consumer->getPrefetchCount()); } public function testCouldSetGetPrefetchCount() { - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $consumer->setPrefetchCount(123); $this->assertSame(123, $consumer->getPrefetchCount()); @@ -78,7 +76,7 @@ public function testAcknowledgeShouldThrowInvalidMessageExceptionIfMessageIsWron $this->expectException(InvalidMessageException::class); $this->expectExceptionMessage('The message must be an instance of'); - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $consumer->acknowledge($this->createMock(Message::class)); } @@ -106,7 +104,7 @@ public function testShouldAcknowledgeMessage() $message = new StompMessage(); $message->setFrame(new Frame()); - $consumer = new StompConsumer($client, new StompDestination()); + $consumer = new StompConsumer($client, $this->createDummyDestination()); $consumer->acknowledge($message); } @@ -115,7 +113,7 @@ public function testRejectShouldThrowInvalidMessageExceptionIfMessageIsWrongType $this->expectException(InvalidMessageException::class); $this->expectExceptionMessage('The message must be an instance of'); - $consumer = new StompConsumer($this->createStompClientMock(), new StompDestination()); + $consumer = new StompConsumer($this->createStompClientMock(), $this->createDummyDestination()); $consumer->reject($this->createMock(Message::class)); } @@ -143,7 +141,7 @@ public function testShouldRejectMessage() $message = new StompMessage(); $message->setFrame(new Frame()); - $consumer = new StompConsumer($client, new StompDestination()); + $consumer = new StompConsumer($client, $this->createDummyDestination()); $consumer->reject($message); $this->assertSame(['requeue' => 'false'], $frame->getHeaders()); @@ -173,7 +171,7 @@ public function testShouldRejectAndRequeueMessage() $message = new StompMessage(); $message->setFrame(new Frame()); - $consumer = new StompConsumer($client, new StompDestination()); + $consumer = new StompConsumer($client, $this->createDummyDestination()); $consumer->reject($message, true); $this->assertSame(['requeue' => 'true'], $frame->getHeaders()); @@ -210,7 +208,7 @@ public function testShouldReceiveMessageNoWait() $message = new StompMessage(); $message->setFrame(new Frame()); - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName('name'); @@ -247,7 +245,7 @@ public function testReceiveMessageNoWaitShouldSubscribeOnlyOnce() $message = new StompMessage(); $message->setFrame(new Frame()); - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName('name'); @@ -280,7 +278,7 @@ public function testShouldAddExtraHeadersOnSubscribe() ->method('readMessageFrame') ; - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setStompName('name'); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setDurable(true); @@ -340,7 +338,7 @@ public function testShouldConvertStompMessageFrameToMessage() ->willReturn($stompMessageFrame) ; - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setStompName('name'); $destination->setType(StompDestination::TYPE_QUEUE); @@ -381,7 +379,7 @@ public function testShouldThrowLogicExceptionIfFrameIsNotMessageFrame() ->willReturn($stompMessageFrame) ; - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setStompName('name'); $destination->setType(StompDestination::TYPE_QUEUE); @@ -418,7 +416,7 @@ public function testShouldReceiveWithUnlimitedTimeout() ->willReturn(new Frame('MESSAGE')) ; - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setStompName('name'); $destination->setType(StompDestination::TYPE_QUEUE); @@ -454,7 +452,7 @@ public function testShouldReceiveWithTimeout() ->willReturn(new Frame('MESSAGE')) ; - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setStompName('name'); $destination->setType(StompDestination::TYPE_QUEUE); @@ -480,7 +478,7 @@ public function testShouldReceiveWithoutSubscribeIfTempQueue() $message = new StompMessage(); $message->setFrame(new Frame()); - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_TEMP_QUEUE); $destination->setStompName('name'); @@ -503,7 +501,7 @@ public function testShouldReceiveNoWaitWithoutSubscribeIfTempQueue() $message = new StompMessage(); $message->setFrame(new Frame()); - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_TEMP_QUEUE); $destination->setStompName('name'); @@ -513,15 +511,15 @@ public function testShouldReceiveNoWaitWithoutSubscribeIfTempQueue() public function testShouldGenerateUniqueSubscriptionIdPerConsumer() { - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName('name'); $fooConsumer = new StompConsumer($this->createStompClientMock(), $destination); $barConsumer = new StompConsumer($this->createStompClientMock(), $destination); - $this->assertAttributeNotEmpty('subscriptionId', $fooConsumer); - $this->assertAttributeNotEmpty('subscriptionId', $barConsumer); + $this->assertNotEmpty($this->readAttribute($fooConsumer, 'subscriptionId')); + $this->assertNotEmpty($this->readAttribute($barConsumer, 'subscriptionId')); $fooSubscriptionId = $this->readAttribute($fooConsumer, 'subscriptionId'); $barSubscriptionId = $this->readAttribute($barConsumer, 'subscriptionId'); @@ -530,7 +528,7 @@ public function testShouldGenerateUniqueSubscriptionIdPerConsumer() public function testShouldUseTempQueueNameAsSubscriptionId() { - $destination = new StompDestination(); + $destination = $this->createDummyDestination(); $destination->setType(StompDestination::TYPE_TEMP_QUEUE); $destination->setStompName('foo'); @@ -540,7 +538,7 @@ public function testShouldUseTempQueueNameAsSubscriptionId() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Protocol + * @return \PHPUnit\Framework\MockObject\MockObject|Protocol */ private function createStompProtocolMock() { @@ -548,10 +546,19 @@ private function createStompProtocolMock() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|BufferedStompClient + * @return \PHPUnit\Framework\MockObject\MockObject|BufferedStompClient */ private function createStompClientMock() { return $this->createMock(BufferedStompClient::class); } + + private function createDummyDestination(): StompDestination + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setStompName('aName'); + $destination->setType(StompDestination::TYPE_QUEUE); + + return $destination; + } } diff --git a/pkg/stomp/Tests/StompContextTest.php b/pkg/stomp/Tests/StompContextTest.php index 0c531c9fa..cfb9245dc 100644 --- a/pkg/stomp/Tests/StompContextTest.php +++ b/pkg/stomp/Tests/StompContextTest.php @@ -2,18 +2,19 @@ namespace Enqueue\Stomp\Tests; -use Enqueue\Psr\Context; -use Enqueue\Psr\InvalidDestinationException; -use Enqueue\Psr\Queue; use Enqueue\Stomp\BufferedStompClient; +use Enqueue\Stomp\ExtensionType; use Enqueue\Stomp\StompConsumer; use Enqueue\Stomp\StompContext; use Enqueue\Stomp\StompDestination; use Enqueue\Stomp\StompMessage; use Enqueue\Stomp\StompProducer; use Enqueue\Test\ClassExtensionTrait; +use Interop\Queue\Context; +use Interop\Queue\Exception\InvalidDestinationException; +use Interop\Queue\Queue; -class StompContextTest extends \PHPUnit_Framework_TestCase +class StompContextTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; @@ -22,14 +23,17 @@ public function testShouldImplementSessionInterface() $this->assertClassImplements(Context::class, StompContext::class); } - public function testCouldBeCreatedWithRequiredArguments() + public function testThrowIfNeitherCallbackNorExtChannelAsFirstArgument() { - new StompContext($this->createStompClientMock()); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The stomp argument must be either BufferedStompClient or callable that return BufferedStompClient.'); + + new StompContext(new \stdClass(), ExtensionType::RABBITMQ); } public function testShouldCreateMessageInstance() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $message = $context->createMessage('the body', ['key' => 'value'], ['hkey' => 'hvalue']); @@ -41,7 +45,7 @@ public function testShouldCreateMessageInstance() public function testShouldCreateQueueInstance() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $queue = $context->createQueue('the name'); @@ -53,7 +57,7 @@ public function testShouldCreateQueueInstance() public function testCreateQueueShouldCreateDestinationIfNameIsFullDestinationString() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $destination = $context->createQueue('/amq/queue/name/routing-key'); @@ -64,9 +68,9 @@ public function testCreateQueueShouldCreateDestinationIfNameIsFullDestinationStr $this->assertEquals('/amq/queue/name/routing-key', $destination->getQueueName()); } - public function testShouldCreateTopicInstance() + public function testShouldCreateTopicInstanceWithExchangePrefix() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $topic = $context->createTopic('the name'); @@ -76,9 +80,21 @@ public function testShouldCreateTopicInstance() $this->assertSame(StompDestination::TYPE_EXCHANGE, $topic->getType()); } + public function testShouldCreateTopicInstanceWithTopicPrefix() + { + $context = new StompContext($this->createStompClientMock(), ExtensionType::ACTIVEMQ); + + $topic = $context->createTopic('the name'); + + $this->assertInstanceOf(StompDestination::class, $topic); + $this->assertSame('/topic/the name', $topic->getQueueName()); + $this->assertSame('/topic/the name', $topic->getTopicName()); + $this->assertSame(StompDestination::TYPE_TOPIC, $topic->getType()); + } + public function testCreateTopicShouldCreateDestinationIfNameIsFullDestinationString() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $destination = $context->createTopic('/amq/queue/name/routing-key'); @@ -94,20 +110,20 @@ public function testThrowInvalidDestinationException() $this->expectException(InvalidDestinationException::class); $this->expectExceptionMessage('The destination must be an instance of'); - $session = new StompContext($this->createStompClientMock()); + $session = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $session->createConsumer($this->createMock(Queue::class)); } public function testShouldCreateMessageConsumerInstance() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); - $this->assertInstanceOf(StompConsumer::class, $context->createConsumer(new StompDestination())); + $this->assertInstanceOf(StompConsumer::class, $context->createConsumer($this->createDummyDestination())); } public function testShouldCreateMessageProducerInstance() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $this->assertInstanceOf(StompProducer::class, $context->createProducer()); } @@ -116,14 +132,14 @@ public function testShouldCloseConnections() { $client = $this->createStompClientMock(); $client - ->expects($this->once()) + ->expects($this->atLeastOnce()) ->method('disconnect') ; - $context = new StompContext($client); + $context = new StompContext($client, ExtensionType::RABBITMQ); $context->createProducer(); - $context->createConsumer(new StompDestination()); + $context->createConsumer($this->createDummyDestination()); $context->close(); } @@ -133,7 +149,7 @@ public function testCreateDestinationShouldThrowLogicExceptionIfTypeIsInvalid() $this->expectException(\LogicException::class); $this->expectExceptionMessage('Destination name is invalid, cant find type: "/invalid-type/name"'); - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $context->createDestination('/invalid-type/name'); } @@ -142,7 +158,7 @@ public function testCreateDestinationShouldThrowLogicExceptionIfExtraSlashFound( $this->expectException(\LogicException::class); $this->expectExceptionMessage('Destination name is invalid, found extra / char: "/queue/name/routing-key/extra'); - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $context->createDestination('/queue/name/routing-key/extra'); } @@ -151,7 +167,7 @@ public function testCreateDestinationShouldThrowLogicExceptionIfNameIsEmpty() $this->expectException(\LogicException::class); $this->expectExceptionMessage('Destination name is invalid, name is empty: "/queue/"'); - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $context->createDestination('/queue/'); } @@ -160,13 +176,13 @@ public function testCreateDestinationShouldThrowLogicExceptionIfRoutingKeyIsEmpt $this->expectException(\LogicException::class); $this->expectExceptionMessage('Destination name is invalid, routing key is empty: "/queue/name/"'); - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $context->createDestination('/queue/name/'); } public function testCreateDestinationShouldParseStringAndCreateDestination() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $destination = $context->createDestination('/amq/queue/name/routing-key'); $this->assertEquals('amq/queue', $destination->getType()); @@ -177,7 +193,7 @@ public function testCreateDestinationShouldParseStringAndCreateDestination() public function testCreateTemporaryQueue() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $tempQueue = $context->createTemporaryQueue(); $this->assertEquals('temp-queue', $tempQueue->getType()); @@ -188,7 +204,7 @@ public function testCreateTemporaryQueue() public function testCreateTemporaryQueuesWithUniqueNames() { - $context = new StompContext($this->createStompClientMock()); + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); $fooTempQueue = $context->createTemporaryQueue(); $barTempQueue = $context->createTemporaryQueue(); @@ -198,11 +214,27 @@ public function testCreateTemporaryQueuesWithUniqueNames() $this->assertNotEquals($fooTempQueue->getStompName(), $barTempQueue->getStompName()); } + public function testShouldGetBufferedStompClient() + { + $context = new StompContext($this->createStompClientMock(), ExtensionType::RABBITMQ); + + $this->assertInstanceOf(BufferedStompClient::class, $context->getStomp()); + } + /** - * @return \PHPUnit_Framework_MockObject_MockObject|BufferedStompClient + * @return \PHPUnit\Framework\MockObject\MockObject|BufferedStompClient */ private function createStompClientMock() { return $this->createMock(BufferedStompClient::class); } + + private function createDummyDestination(): StompDestination + { + $destination = new StompDestination(ExtensionType::RABBITMQ); + $destination->setStompName('aName'); + $destination->setType(StompDestination::TYPE_QUEUE); + + return $destination; + } } diff --git a/pkg/stomp/Tests/StompDestinationTest.php b/pkg/stomp/Tests/StompDestinationTest.php index d0a9e82ef..5061655f8 100644 --- a/pkg/stomp/Tests/StompDestinationTest.php +++ b/pkg/stomp/Tests/StompDestinationTest.php @@ -2,12 +2,13 @@ namespace Enqueue\Stomp\Tests; -use Enqueue\Psr\Queue; -use Enqueue\Psr\Topic; +use Enqueue\Stomp\ExtensionType; use Enqueue\Stomp\StompDestination; use Enqueue\Test\ClassExtensionTrait; +use Interop\Queue\Queue; +use Interop\Queue\Topic; -class StompDestinationTest extends \PHPUnit_Framework_TestCase +class StompDestinationTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; @@ -19,7 +20,7 @@ public function testShouldImplementsTopicAndQueueInterfaces() public function testShouldReturnDestinationStringWithRoutingKey() { - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType(StompDestination::TYPE_AMQ_QUEUE); $destination->setStompName('name'); $destination->setRoutingKey('routing-key'); @@ -32,7 +33,7 @@ public function testShouldReturnDestinationStringWithRoutingKey() public function testShouldReturnDestinationStringWithoutRoutingKey() { - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType(StompDestination::TYPE_TOPIC); $destination->setStompName('name'); @@ -45,21 +46,11 @@ public function testShouldReturnDestinationStringWithoutRoutingKey() public function testShouldThrowLogicExceptionIfNameIsNotSet() { $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Destination type or name is not set'); + $this->expectExceptionMessage('Destination name is not set'); - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType(StompDestination::TYPE_QUEUE); - - $destination->getQueueName(); - } - - public function testShouldThrowLogicExceptionIfTypeIsNotSet() - { - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Destination type or name is not set'); - - $destination = new StompDestination(); - $destination->setStompName('name'); + $destination->setStompName(''); $destination->getQueueName(); } @@ -69,7 +60,7 @@ public function testSetTypeShouldThrowLogicExceptionIfTypeIsInvalid() $this->expectException(\LogicException::class); $this->expectExceptionMessage('Invalid destination type: "invalid-type"'); - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType('invalid-type'); } } diff --git a/pkg/stomp/Tests/StompHeadersEncoderTest.php b/pkg/stomp/Tests/StompHeadersEncoderTest.php index b8ce07a6c..cd3d112dd 100644 --- a/pkg/stomp/Tests/StompHeadersEncoderTest.php +++ b/pkg/stomp/Tests/StompHeadersEncoderTest.php @@ -4,7 +4,7 @@ use Enqueue\Stomp\StompHeadersEncoder; -class StompHeadersEncoderTest extends \PHPUnit_Framework_TestCase +class StompHeadersEncoderTest extends \PHPUnit\Framework\TestCase { public function headerValuesDataProvider() { @@ -32,8 +32,6 @@ public function propertyValuesDataProvider() /** * @dataProvider headerValuesDataProvider - * @param mixed $originalValue - * @param mixed $encodedValue */ public function testShouldEncodeHeaders($originalValue, $encodedValue) { @@ -42,8 +40,6 @@ public function testShouldEncodeHeaders($originalValue, $encodedValue) /** * @dataProvider propertyValuesDataProvider - * @param mixed $originalValue - * @param mixed $encodedValue */ public function testShouldEncodeProperties($originalValue, $encodedValue) { @@ -52,8 +48,6 @@ public function testShouldEncodeProperties($originalValue, $encodedValue) /** * @dataProvider headerValuesDataProvider - * @param mixed $originalValue - * @param mixed $encodedValue */ public function testShouldDecodeHeaders($originalValue, $encodedValue) { @@ -62,8 +56,6 @@ public function testShouldDecodeHeaders($originalValue, $encodedValue) /** * @dataProvider propertyValuesDataProvider - * @param mixed $originalValue - * @param mixed $encodedValue */ public function testShouldDecodeProperties($originalValue, $encodedValue) { diff --git a/pkg/stomp/Tests/StompMessageTest.php b/pkg/stomp/Tests/StompMessageTest.php index c434e2d4d..49be1a8c5 100644 --- a/pkg/stomp/Tests/StompMessageTest.php +++ b/pkg/stomp/Tests/StompMessageTest.php @@ -2,63 +2,30 @@ namespace Enqueue\Stomp\Tests; -use Enqueue\Psr\Message; use Enqueue\Stomp\StompMessage; use Enqueue\Test\ClassExtensionTrait; use Stomp\Transport\Frame; -class StompMessageTest extends \PHPUnit_Framework_TestCase +class StompMessageTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; - public function testShouldImplementMessageInterface() - { - $this->assertClassImplements(Message::class, StompMessage::class); - } - - public function testCouldConstructMessageWithBody() - { - $message = new StompMessage('body'); - - $this->assertSame('body', $message->getBody()); - } - - public function testCouldConstructMessageWithProperties() - { - $message = new StompMessage('', ['key' => 'value']); - - $this->assertSame(['key' => 'value'], $message->getProperties()); - } - - public function testCouldConstructMessageWithHeaders() - { - $message = new StompMessage('', [], ['key' => 'value']); - - $this->assertSame(['key' => 'value'], $message->getHeaders()); - } - - public function testCouldSetGetBody() + public function testCouldBeConstructedWithoutArguments() { $message = new StompMessage(); - $message->setBody('body'); - $this->assertSame('body', $message->getBody()); + $this->assertSame('', $message->getBody()); + $this->assertSame([], $message->getProperties()); + $this->assertSame([], $message->getHeaders()); } - public function testCouldSetGetProperties() + public function testCouldBeConstructedWithOptionalArguments() { - $message = new StompMessage(); - $message->setProperties(['key' => 'value']); - - $this->assertSame(['key' => 'value'], $message->getProperties()); - } - - public function testCouldSetGetHeaders() - { - $message = new StompMessage(); - $message->setHeaders(['key' => 'value']); + $message = new StompMessage('theBody', ['barProp' => 'barPropVal'], ['fooHeader' => 'fooHeaderVal']); - $this->assertSame(['key' => 'value'], $message->getHeaders()); + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['barProp' => 'barPropVal'], $message->getProperties()); + $this->assertSame(['fooHeader' => 'fooHeaderVal'], $message->getHeaders()); } public function testCouldSetGetPersistent() @@ -80,25 +47,6 @@ public function testShouldSetPersistentAsHeader() $this->assertSame(['persistent' => true], $message->getHeaders()); } - public function testCouldSetGetRedelivered() - { - $message = new StompMessage(); - - $message->setRedelivered(true); - $this->assertTrue($message->isRedelivered()); - - $message->setRedelivered(false); - $this->assertFalse($message->isRedelivered()); - } - - public function testCouldSetGetCorrelationId() - { - $message = new StompMessage(); - $message->setCorrelationId('the-correlation-id'); - - $this->assertSame('the-correlation-id', $message->getCorrelationId()); - } - public function testShouldSetCorrelationIdAsHeader() { $message = new StompMessage(); @@ -107,14 +55,6 @@ public function testShouldSetCorrelationIdAsHeader() $this->assertSame(['correlation_id' => 'the-correlation-id'], $message->getHeaders()); } - public function testCouldSetGetMessageId() - { - $message = new StompMessage(); - $message->setMessageId('the-message-id'); - - $this->assertSame('the-message-id', $message->getMessageId()); - } - public function testCouldSetMessageIdAsHeader() { $message = new StompMessage(); @@ -123,14 +63,6 @@ public function testCouldSetMessageIdAsHeader() $this->assertSame(['message_id' => 'the-message-id'], $message->getHeaders()); } - public function testCouldSetGetTimestamp() - { - $message = new StompMessage(); - $message->setTimestamp(12345); - - $this->assertSame(12345, $message->getTimestamp()); - } - public function testCouldSetTimestampAsHeader() { $message = new StompMessage(); @@ -147,26 +79,41 @@ public function testCouldSetGetFrame() $this->assertSame($frame, $message->getFrame()); } - public function testShouldReturnNullAsDefaultReplyTo() + public function testShouldSetReplyToAsHeader() { $message = new StompMessage(); + $message->setReplyTo('theQueueName'); - self::assertSame(null, $message->getReplyTo()); + self::assertSame(['reply-to' => 'theQueueName'], $message->getHeaders()); } - public function testShouldAllowGetPreviouslySetReplyTo() + public function testShouldUnsetHeaderIfNullPassed() { $message = new StompMessage(); - $message->setReplyTo('theQueueName'); - self::assertSame('theQueueName', $message->getReplyTo()); + $message->setHeader('aHeader', 'aVal'); + + // guard + $this->assertSame('aVal', $message->getHeader('aHeader')); + + $message->setHeader('aHeader', null); + + $this->assertNull($message->getHeader('aHeader')); + $this->assertSame([], $message->getHeaders()); } - public function testShouldAllowGetPreviouslySetReplyToAsHeader() + public function testShouldUnsetPropertyIfNullPassed() { $message = new StompMessage(); - $message->setReplyTo('theQueueName'); - self::assertSame(['reply-to' => 'theQueueName'], $message->getHeaders()); + $message->setProperty('aProperty', 'aVal'); + + // guard + $this->assertSame('aVal', $message->getProperty('aProperty')); + + $message->setProperty('aProperty', null); + + $this->assertNull($message->getProperty('aProperty')); + $this->assertSame([], $message->getProperties()); } } diff --git a/pkg/stomp/Tests/StompProducerTest.php b/pkg/stomp/Tests/StompProducerTest.php index 3d58d5e80..41f35256c 100644 --- a/pkg/stomp/Tests/StompProducerTest.php +++ b/pkg/stomp/Tests/StompProducerTest.php @@ -2,23 +2,24 @@ namespace Enqueue\Stomp\Tests; -use Enqueue\Psr\InvalidDestinationException; -use Enqueue\Psr\InvalidMessageException; -use Enqueue\Psr\Message as PsrMessage; -use Enqueue\Psr\Producer; -use Enqueue\Psr\Queue; +use Enqueue\Stomp\ExtensionType; use Enqueue\Stomp\StompDestination; use Enqueue\Stomp\StompMessage; use Enqueue\Stomp\StompProducer; use Enqueue\Test\ClassExtensionTrait; +use Interop\Queue\Exception\InvalidDestinationException; +use Interop\Queue\Exception\InvalidMessageException; +use Interop\Queue\Message; +use Interop\Queue\Producer; +use Interop\Queue\Queue; use Stomp\Client; -use Stomp\Transport\Message; +use Stomp\Transport\Message as VendorMessage; -class StompProducerTest extends \PHPUnit_Framework_TestCase +class StompProducerTest extends \PHPUnit\Framework\TestCase { use ClassExtensionTrait; - public function testShouldImplementMessageProducerInterface() + public function testShouldImplementProducerInterface() { $this->assertClassImplements(Producer::class, StompProducer::class); } @@ -40,7 +41,7 @@ public function testShouldThrowInvalidMessageExceptionWhenMessageIsWrongType() $producer = new StompProducer($this->createStompClientMock()); - $producer->send(new StompDestination(), $this->createMock(PsrMessage::class)); + $producer->send(new StompDestination(ExtensionType::RABBITMQ), $this->createMock(Message::class)); } public function testShouldSendMessage() @@ -49,12 +50,12 @@ public function testShouldSendMessage() $client ->expects($this->once()) ->method('send') - ->with('/queue/name', $this->isInstanceOf(Message::class)) + ->with('/queue/name', $this->isInstanceOf(VendorMessage::class)) ; $producer = new StompProducer($client); - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName('name'); @@ -68,7 +69,7 @@ public function testShouldEncodeMessageHeadersAndProperties() $client ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($destination, Message $message) use (&$stompMessage) { + ->willReturnCallback(function ($destination, VendorMessage $message) use (&$stompMessage) { $stompMessage = $message; }) ; @@ -77,7 +78,7 @@ public function testShouldEncodeMessageHeadersAndProperties() $message = new StompMessage('', ['key' => 'value'], ['hkey' => false]); - $destination = new StompDestination(); + $destination = new StompDestination(ExtensionType::RABBITMQ); $destination->setType(StompDestination::TYPE_QUEUE); $destination->setStompName('name'); @@ -100,7 +101,7 @@ public function testShouldEncodeMessageHeadersAndProperties() } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Client + * @return \PHPUnit\Framework\MockObject\MockObject|Client */ private function createStompClientMock() { diff --git a/pkg/stomp/Tests/Symfony/RabbitMqStompTransportFactoryTest.php b/pkg/stomp/Tests/Symfony/RabbitMqStompTransportFactoryTest.php deleted file mode 100644 index d9bdb33e7..000000000 --- a/pkg/stomp/Tests/Symfony/RabbitMqStompTransportFactoryTest.php +++ /dev/null @@ -1,136 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, RabbitMqStompTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new RabbitMqStompTransportFactory(); - - $this->assertEquals('rabbitmq_stomp', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new RabbitMqStompTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new RabbitMqStompTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), []); - - $this->assertEquals([ - 'host' => 'localhost', - 'port' => 61613, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - 'delay_plugin_installed' => false, - 'management_plugin_installed' => false, - 'management_plugin_port' => 15672, - ], $config); - } - - public function testShouldCreateService() - { - $container = new ContainerBuilder(); - - $transport = new RabbitMqStompTransportFactory(); - - $serviceId = $transport->createContext($container, [ - 'uri' => 'tcp://localhost:61613', - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - 'delay_plugin_installed' => false, - ]); - - $this->assertEquals('enqueue.transport.rabbitmq_stomp.context', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $context = $container->getDefinition('enqueue.transport.rabbitmq_stomp.context'); - $this->assertInstanceOf(Reference::class, $context->getFactory()[0]); - $this->assertEquals('enqueue.transport.rabbitmq_stomp.connection_factory', (string) $context->getFactory()[0]); - $this->assertEquals('createContext', $context->getFactory()[1]); - - $this->assertTrue($container->hasDefinition('enqueue.transport.rabbitmq_stomp.connection_factory')); - $factory = $container->getDefinition('enqueue.transport.rabbitmq_stomp.connection_factory'); - $this->assertEquals(StompConnectionFactory::class, $factory->getClass()); - $this->assertSame([[ - 'uri' => 'tcp://localhost:61613', - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - 'delay_plugin_installed' => false, - ]], $factory->getArguments()); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new RabbitMqStompTransportFactory(); - - $serviceId = $transport->createDriver($container, [ - 'vhost' => 'vhost', - 'host' => 'host', - 'management_plugin_port' => 'port', - 'login' => 'login', - 'password' => 'password', - ]); - - $this->assertTrue($container->hasDefinition('enqueue.client.rabbitmq_stomp.management_client')); - $managementClient = $container->getDefinition('enqueue.client.rabbitmq_stomp.management_client'); - $this->assertEquals(ManagementClient::class, $managementClient->getClass()); - $this->assertEquals([ManagementClient::class, 'create'], $managementClient->getFactory()); - $this->assertEquals([ - 'vhost', - 'host', - 'port', - 'login', - 'password', - ], $managementClient->getArguments()); - - $this->assertEquals('enqueue.client.rabbitmq_stomp.driver', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $driver = $container->getDefinition($serviceId); - $this->assertSame(RabbitMqStompDriver::class, $driver->getClass()); - } -} diff --git a/pkg/stomp/Tests/Symfony/StompTransportFactoryTest.php b/pkg/stomp/Tests/Symfony/StompTransportFactoryTest.php deleted file mode 100644 index c147e10f2..000000000 --- a/pkg/stomp/Tests/Symfony/StompTransportFactoryTest.php +++ /dev/null @@ -1,112 +0,0 @@ -assertClassImplements(TransportFactoryInterface::class, StompTransportFactory::class); - } - - public function testCouldBeConstructedWithDefaultName() - { - $transport = new StompTransportFactory(); - - $this->assertEquals('stomp', $transport->getName()); - } - - public function testCouldBeConstructedWithCustomName() - { - $transport = new StompTransportFactory('theCustomName'); - - $this->assertEquals('theCustomName', $transport->getName()); - } - - public function testShouldAllowAddConfiguration() - { - $transport = new StompTransportFactory(); - $tb = new TreeBuilder(); - $rootNode = $tb->root('foo'); - - $transport->addConfiguration($rootNode); - $processor = new Processor(); - $config = $processor->process($tb->buildTree(), []); - - $this->assertEquals([ - 'host' => 'localhost', - 'port' => 61613, - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - ], $config); - } - - public function testShouldCreateService() - { - $container = new ContainerBuilder(); - - $transport = new StompTransportFactory(); - - $serviceId = $transport->createContext($container, [ - 'uri' => 'tcp://localhost:61613', - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - ]); - - $this->assertEquals('enqueue.transport.stomp.context', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $context = $container->getDefinition('enqueue.transport.stomp.context'); - $this->assertInstanceOf(Reference::class, $context->getFactory()[0]); - $this->assertEquals('enqueue.transport.stomp.connection_factory', (string) $context->getFactory()[0]); - $this->assertEquals('createContext', $context->getFactory()[1]); - - $this->assertTrue($container->hasDefinition('enqueue.transport.stomp.connection_factory')); - $factory = $container->getDefinition('enqueue.transport.stomp.connection_factory'); - $this->assertEquals(StompConnectionFactory::class, $factory->getClass()); - $this->assertSame([[ - 'uri' => 'tcp://localhost:61613', - 'login' => 'guest', - 'password' => 'guest', - 'vhost' => '/', - 'sync' => true, - 'connection_timeout' => 1, - 'buffer_size' => 1000, - ]], $factory->getArguments()); - } - - public function testShouldCreateDriver() - { - $container = new ContainerBuilder(); - - $transport = new StompTransportFactory(); - - $serviceId = $transport->createDriver($container, []); - - $this->assertEquals('enqueue.client.stomp.driver', $serviceId); - $this->assertTrue($container->hasDefinition($serviceId)); - - $driver = $container->getDefinition($serviceId); - $this->assertSame(StompDriver::class, $driver->getClass()); - } -} diff --git a/pkg/stomp/composer.json b/pkg/stomp/composer.json index 24ea1d4a2..2cceb9fea 100644 --- a/pkg/stomp/composer.json +++ b/pkg/stomp/composer.json @@ -3,27 +3,31 @@ "type": "library", "description": "Message Queue Stomp Transport", "keywords": ["messaging", "queue", "stomp"], + "homepage": "https://enqueue.forma-pro.com/", "license": "MIT", - "repositories": [ - { - "type": "vcs", - "url": "git@github.com:php-enqueue/test.git" - } - ], "require": { - "php": ">=5.6", - "stomp-php/stomp-php": "^4", - "enqueue/psr-queue": "^0.2", - "php-http/guzzle6-adapter": "^1.1", - "richardfullmer/rabbitmq-management-api": "^2.0", - "psr/log": "^1" + "php": "^8.1", + "enqueue/dsn": "^0.10", + "stomp-php/stomp-php": "^4.5|^5.0", + "queue-interop/queue-interop": "^0.8", + "php-http/guzzle7-adapter": "^0.1.1", + "php-http/client-common": "^2.2.1", + "andrewmy/rabbitmq-management-api": "^2.1.2", + "guzzlehttp/guzzle": "^7.0.1", + "php-http/discovery": "^1.13" }, "require-dev": { - "phpunit/phpunit": "~5.4.0", - "enqueue/test": "^0.2", - "enqueue/enqueue": "^0.2", - "symfony/dependency-injection": "^2.8|^3", - "symfony/config": "^2.8|^3" + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" }, "autoload": { "psr-4": { "Enqueue\\Stomp\\": "" }, @@ -31,13 +35,10 @@ "/Tests/" ] }, - "suggest": { - "enqueue/enqueue": "If you'd like to use advanced features like Client abstract layer or Symfony integration features" - }, "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } } } diff --git a/pkg/stomp/examples/consume.php b/pkg/stomp/examples/consume.php deleted file mode 100644 index 36d9e5a34..000000000 --- a/pkg/stomp/examples/consume.php +++ /dev/null @@ -1,52 +0,0 @@ - getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__STOMP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), - 'sync' => true, -]; - -try { - $factory = new StompConnectionFactory($config); - $context = $factory->createContext(); - - $destination = $context->createQueue('destination'); - $destination->setDurable(true); - $destination->setAutoDelete(false); - - $consumer = $context->createConsumer($destination); - - while (true) { - if ($message = $consumer->receive()) { - $consumer->acknowledge($message); - - var_dump($message->getBody()); - var_dump($message->getProperties()); - var_dump($message->getHeaders()); - echo '-------------------------------------'.PHP_EOL; - } - } -} catch (ErrorFrameException $e) { - var_dump($e->getFrame()); -} diff --git a/pkg/stomp/examples/publish.php b/pkg/stomp/examples/publish.php deleted file mode 100644 index 47985885c..000000000 --- a/pkg/stomp/examples/publish.php +++ /dev/null @@ -1,48 +0,0 @@ - getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__STOMP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), - 'sync' => true, -]; - -try { - $factory = new StompConnectionFactory($config); - $context = $factory->createContext(); - - $destination = $context->createQueue('destination'); - $destination->setDurable(true); - $destination->setAutoDelete(false); - - $producer = $context->createProducer(); - - $i = 1; - while (true) { - $message = $context->createMessage('payload: '.$i++); - $producer->send($destination, $message); - usleep(1000); - } -} catch (ErrorFrameException $e) { - var_dump($e->getFrame()); -} diff --git a/pkg/stomp/phpunit.xml.dist b/pkg/stomp/phpunit.xml.dist index a9291bf93..ae7136aca 100644 --- a/pkg/stomp/phpunit.xml.dist +++ b/pkg/stomp/phpunit.xml.dist @@ -1,16 +1,11 @@ - + diff --git a/pkg/test/ClassExtensionTrait.php b/pkg/test/ClassExtensionTrait.php index 9bd326a7f..75d70ae63 100644 --- a/pkg/test/ClassExtensionTrait.php +++ b/pkg/test/ClassExtensionTrait.php @@ -10,7 +10,7 @@ public function assertClassExtends($expected, $actual) $this->assertTrue( $rc->isSubclassOf($expected), - sprintf('Failed assert that class %s extends %s class.', $actual, $expected) + sprintf('Failed assert that class %s extends %s class', $actual, $expected) ); } @@ -23,4 +23,24 @@ public function assertClassImplements($expected, $actual) sprintf('Failed assert that class %s implements %s interface.', $actual, $expected) ); } + + public function assertClassFinal($actual) + { + $rc = new \ReflectionClass($actual); + + $this->assertTrue( + $rc->isFinal(), + sprintf('Failed assert that class %s is final.', $actual) + ); + } + + public function assertClassNotFinal($actual) + { + $rc = new \ReflectionClass($actual); + + $this->assertFalse( + $rc->isFinal(), + sprintf('Failed assert that class %s is final.', $actual) + ); + } } diff --git a/pkg/test/GpsExtension.php b/pkg/test/GpsExtension.php new file mode 100644 index 000000000..2b03ef64b --- /dev/null +++ b/pkg/test/GpsExtension.php @@ -0,0 +1,21 @@ +createContext(); + } +} diff --git a/pkg/test/MongodbExtensionTrait.php b/pkg/test/MongodbExtensionTrait.php new file mode 100644 index 000000000..3ba9e93e0 --- /dev/null +++ b/pkg/test/MongodbExtensionTrait.php @@ -0,0 +1,24 @@ +markTestSkipped('The MONGO_DSN env is not available. Skip tests'); + } + + $factory = new MongodbConnectionFactory(['dsn' => $env]); + + $context = $factory->createContext(); + + return $context; + } +} diff --git a/pkg/test/README.md b/pkg/test/README.md index af004d5df..a2411c1b3 100644 --- a/pkg/test/README.md +++ b/pkg/test/README.md @@ -1,15 +1,33 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + # Message Queue. Test utils [![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) - -Contains stuff needed in tests. Shared among different packages. + +Contains stuff needed in tests. Shared among different packages. ## Resources -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + ## License -It is released under the [MIT License](LICENSE). \ No newline at end of file +It is released under the [MIT License](LICENSE). diff --git a/pkg/test/RabbitManagementExtensionTrait.php b/pkg/test/RabbitManagementExtensionTrait.php new file mode 100644 index 000000000..184b1758e --- /dev/null +++ b/pkg/test/RabbitManagementExtensionTrait.php @@ -0,0 +1,76 @@ +getHost(), + urlencode(ltrim($dsn->getPath(), '/')), + $queueName + ); + + $ch = curl_init(); + curl_setopt($ch, \CURLOPT_URL, $url); + curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, \CURLOPT_HTTPAUTH, \CURLAUTH_BASIC); + curl_setopt($ch, \CURLOPT_USERPWD, $dsn->getUser().':'.$dsn->getPassword()); + curl_setopt($ch, \CURLOPT_HTTPHEADER, [ + 'Content-Type' => 'application/json', + ]); + curl_exec($ch); + + $httpCode = curl_getinfo($ch, \CURLINFO_HTTP_CODE); + + curl_close($ch); + + if (false == in_array($httpCode, [204, 404], true)) { + throw new \LogicException('Failed to remove queue. The response status is '.$httpCode); + } + } + + /** + * @param string $exchangeName + */ + private function removeExchange($exchangeName) + { + $dsn = Dsn::parseFirst(getenv('RABBITMQ_AMQP_DSN')); + + $url = sprintf( + 'http://%s:15672/api/exchanges/%s/%s', + $dsn->getHost(), + urlencode(ltrim($dsn->getPath(), '/')), + $exchangeName + ); + + $ch = curl_init(); + curl_setopt($ch, \CURLOPT_URL, $url); + curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, \CURLOPT_HTTPAUTH, \CURLAUTH_BASIC); + curl_setopt($ch, \CURLOPT_USERPWD, $dsn->getUser().':'.$dsn->getPassword()); + curl_setopt($ch, \CURLOPT_HTTPHEADER, [ + 'Content-Type' => 'application/json', + ]); + curl_exec($ch); + + $httpCode = curl_getinfo($ch, \CURLINFO_HTTP_CODE); + + curl_close($ch); + + if (false == in_array($httpCode, [204, 404], true)) { + throw new \LogicException('Failed to remove queue. The response status is '.$httpCode); + } + } +} diff --git a/pkg/test/RabbitmqAmqpExtension.php b/pkg/test/RabbitmqAmqpExtension.php index e1dba0f1a..28099f12b 100644 --- a/pkg/test/RabbitmqAmqpExtension.php +++ b/pkg/test/RabbitmqAmqpExtension.php @@ -4,6 +4,7 @@ use Enqueue\AmqpExt\AmqpConnectionFactory; use Enqueue\AmqpExt\AmqpContext; +use PHPUnit\Framework\SkippedTestError; trait RabbitmqAmqpExtension { @@ -12,18 +13,10 @@ trait RabbitmqAmqpExtension */ private function buildAmqpContext() { - if (false == getenv('SYMFONY__RABBITMQ__HOST')) { - throw new \PHPUnit_Framework_SkippedTestError('Functional tests are not allowed in this environment'); + if (false == $dsn = getenv('AMQP_DSN')) { + throw new SkippedTestError('Functional tests are not allowed in this environment'); } - $config = [ - 'host' => getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__AMQP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), - ]; - - return (new AmqpConnectionFactory($config))->createContext(); + return (new AmqpConnectionFactory($dsn))->createContext(); } } diff --git a/pkg/test/RabbitmqManagmentExtensionTrait.php b/pkg/test/RabbitmqManagmentExtensionTrait.php deleted file mode 100644 index b7c4a4440..000000000 --- a/pkg/test/RabbitmqManagmentExtensionTrait.php +++ /dev/null @@ -1,80 +0,0 @@ - 'application/json', - ]); - curl_exec($ch); - - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - curl_close($ch); - - if (false == in_array($httpCode, [204, 404], true)) { - throw new \LogicException('Failed to remove queue. The response status is '.$httpCode); - } - } - - /** - * @param string $exchangeName - */ - private function removeExchange($exchangeName) - { - $rabbitmqHost = getenv('SYMFONY__RABBITMQ__HOST'); - $rabbitmqUser = getenv('SYMFONY__RABBITMQ__USER'); - $rabbitmqPassword = getenv('SYMFONY__RABBITMQ__PASSWORD'); - $rabbitmqVhost = getenv('SYMFONY__RABBITMQ__VHOST'); - - $url = sprintf( - 'http://%s:15672/api/exchanges/%s/%s', - $rabbitmqHost, - urlencode($rabbitmqVhost), - $exchangeName - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - curl_setopt($ch, CURLOPT_USERPWD, $rabbitmqUser.':'.$rabbitmqPassword); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type' => 'application/json', - ]); - curl_exec($ch); - - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - curl_close($ch); - - if (false == in_array($httpCode, [204, 404], true)) { - throw new \LogicException('Failed to remove queue. The response status is '.$httpCode); - } - } -} diff --git a/pkg/test/RabbitmqStompExtension.php b/pkg/test/RabbitmqStompExtension.php index ce3b80312..240f67edb 100644 --- a/pkg/test/RabbitmqStompExtension.php +++ b/pkg/test/RabbitmqStompExtension.php @@ -4,27 +4,21 @@ use Enqueue\Stomp\StompConnectionFactory; use Enqueue\Stomp\StompContext; +use PHPUnit\Framework\SkippedTestError; trait RabbitmqStompExtension { - /** - * @return StompContext - */ - private function buildStompContext() + private function getDsn() { - if (false == getenv('SYMFONY__RABBITMQ__HOST')) { - throw new \PHPUnit_Framework_SkippedTestError('Functional tests are not allowed in this environment'); - } + return getenv('RABITMQ_STOMP_DSN'); + } - $config = [ - 'host' => getenv('SYMFONY__RABBITMQ__HOST'), - 'port' => getenv('SYMFONY__RABBITMQ__STOMP__PORT'), - 'login' => getenv('SYMFONY__RABBITMQ__USER'), - 'password' => getenv('SYMFONY__RABBITMQ__PASSWORD'), - 'vhost' => getenv('SYMFONY__RABBITMQ__VHOST'), - 'sync' => true, - ]; + private function buildStompContext(): StompContext + { + if (false == $dsn = $this->getDsn()) { + throw new SkippedTestError('Functional tests are not allowed in this environment'); + } - return (new StompConnectionFactory($config))->createContext(); + return (new StompConnectionFactory($dsn))->createContext(); } } diff --git a/pkg/test/ReadAttributeTrait.php b/pkg/test/ReadAttributeTrait.php new file mode 100644 index 000000000..5b9758a64 --- /dev/null +++ b/pkg/test/ReadAttributeTrait.php @@ -0,0 +1,57 @@ +getClassAttribute($object, $attribute); + $refProperty->setAccessible(true); + $value = $refProperty->getValue($object); + $refProperty->setAccessible(false); + + return $value; + } + + private function getClassAttribute( + object $object, + string $attribute, + ?string $class = null, + ): \ReflectionProperty { + if (null === $class) { + $class = $object::class; + } + + try { + return new \ReflectionProperty($class, $attribute); + } catch (\ReflectionException $exception) { + $parentClass = get_parent_class($object); + if (false === $parentClass) { + throw $exception; + } + + return $this->getClassAttribute($object, $attribute, $parentClass); + } + } + + private function assertAttributeSame($expected, string $attribute, object $object): void + { + static::assertSame($expected, $this->readAttribute($object, $attribute)); + } + + private function assertAttributeEquals($expected, string $attribute, object $object): void + { + static::assertEquals($expected, $this->readAttribute($object, $attribute)); + } + + private function assertAttributeInstanceOf(string $expected, string $attribute, object $object): void + { + static::assertInstanceOf($expected, $this->readAttribute($object, $attribute)); + } + + private function assertAttributeCount(int $count, string $attribute, object $object): void + { + static::assertCount($count, $this->readAttribute($object, $attribute)); + } +} diff --git a/pkg/test/RedisExtension.php b/pkg/test/RedisExtension.php new file mode 100644 index 000000000..3227785c2 --- /dev/null +++ b/pkg/test/RedisExtension.php @@ -0,0 +1,44 @@ +createContext(); + + // guard + $this->assertInstanceOf(PhpRedis::class, $context->getRedis()); + + return $context; + } + + private function buildPRedisContext(): RedisContext + { + if (false == getenv('PREDIS_DSN')) { + throw new SkippedTestError('Functional tests are not allowed in this environment'); + } + + $config = getenv('PREDIS_DSN'); + + $context = (new RedisConnectionFactory($config))->createContext(); + + // guard + $this->assertInstanceOf(PRedis::class, $context->getRedis()); + + return $context; + } +} diff --git a/pkg/test/RetryTrait.php b/pkg/test/RetryTrait.php new file mode 100644 index 000000000..1f1042c81 --- /dev/null +++ b/pkg/test/RetryTrait.php @@ -0,0 +1,62 @@ +getNumberOfRetries(); + if (false == is_numeric($numberOfRetires)) { + throw new \LogicException(sprintf('The $numberOfRetires must be a number but got "%s"', var_export($numberOfRetires, true))); + } + $numberOfRetires = (int) $numberOfRetires; + if ($numberOfRetires <= 0) { + throw new \LogicException(sprintf('The $numberOfRetires must be a positive number greater than 0 but got "%s".', $numberOfRetires)); + } + + for ($i = 0; $i < $numberOfRetires; ++$i) { + try { + parent::runBare(); + + return; + } catch (IncompleteTestError $e) { + throw $e; + } catch (SkippedTestError $e) { + throw $e; + } catch (\Throwable $e) { + // last one thrown below + } catch (\Exception $e) { + // last one thrown below + } + } + + if ($e) { + throw $e; + } + } + + /** + * @return int + */ + private function getNumberOfRetries() + { + $annotations = Test::parseTestMethodAnnotations(static::class, $this->getName(false)); + + if (isset($annotations['method']['retry'][0])) { + return $annotations['method']['retry'][0]; + } + + if (isset($annotations['class']['retry'][0])) { + return $annotations['class']['retry'][0]; + } + + return 1; + } +} diff --git a/pkg/test/SnsExtension.php b/pkg/test/SnsExtension.php new file mode 100644 index 000000000..050f212dd --- /dev/null +++ b/pkg/test/SnsExtension.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/test/SnsQsExtension.php b/pkg/test/SnsQsExtension.php new file mode 100644 index 000000000..6dc7dc9d9 --- /dev/null +++ b/pkg/test/SnsQsExtension.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/test/SqsExtension.php b/pkg/test/SqsExtension.php new file mode 100644 index 000000000..c00b42e68 --- /dev/null +++ b/pkg/test/SqsExtension.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/test/TestLogger.php b/pkg/test/TestLogger.php new file mode 100644 index 000000000..9db2c2a5e --- /dev/null +++ b/pkg/test/TestLogger.php @@ -0,0 +1,144 @@ + 0) { + $genericMethod = $matches[1].('Records' !== $matches[3] ? 'Record' : '').$matches[3]; + $level = strtolower($matches[2]); + if (method_exists($this, $genericMethod)) { + $args[] = $level; + + return call_user_func_array([$this, $genericMethod], $args); + } + } + throw new \BadMethodCallException('Call to undefined method TestLogger::'.$method.'()'); + } + + public function log($level, $message, array $context = []): void + { + $record = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + + $this->recordsByLevel[$record['level']][] = $record; + $this->records[] = $record; + } + + public function hasRecords($level) + { + return isset($this->recordsByLevel[$level]); + } + + public function hasRecord($record, $level) + { + if (is_string($record)) { + $record = ['message' => $record]; + } + + return $this->hasRecordThatPasses(function ($rec) use ($record) { + if ($rec['message'] !== $record['message']) { + return false; + } + if (isset($record['context']) && $rec['context'] !== $record['context']) { + return false; + } + + return true; + }, $level); + } + + public function hasRecordThatContains($message, $level) + { + return $this->hasRecordThatPasses(function ($rec) use ($message) { + return str_contains($rec['message'], $message); + }, $level); + } + + public function hasRecordThatMatches($regex, $level) + { + return $this->hasRecordThatPasses(function ($rec) use ($regex) { + return preg_match($regex, $rec['message']) > 0; + }, $level); + } + + public function hasRecordThatPasses(callable $predicate, $level) + { + if (!isset($this->recordsByLevel[$level])) { + return false; + } + foreach ($this->recordsByLevel[$level] as $i => $rec) { + if (call_user_func($predicate, $rec, $i)) { + return true; + } + } + + return false; + } + + public function reset() + { + $this->records = []; + $this->recordsByLevel = []; + } +} diff --git a/pkg/test/WampExtension.php b/pkg/test/WampExtension.php new file mode 100644 index 000000000..5b17fe7cf --- /dev/null +++ b/pkg/test/WampExtension.php @@ -0,0 +1,19 @@ +createContext(); + } +} diff --git a/pkg/test/WriteAttributeTrait.php b/pkg/test/WriteAttributeTrait.php index e2e84bd2a..6f8c1aab5 100644 --- a/pkg/test/WriteAttributeTrait.php +++ b/pkg/test/WriteAttributeTrait.php @@ -7,11 +7,10 @@ trait WriteAttributeTrait /** * @param object $object * @param string $attribute - * @param mixed $value */ public function writeAttribute($object, $attribute, $value) { - $refProperty = new \ReflectionProperty(get_class($object), $attribute); + $refProperty = new \ReflectionProperty($object::class, $attribute); $refProperty->setAccessible(true); $refProperty->setValue($object, $value); $refProperty->setAccessible(false); diff --git a/pkg/test/composer.json b/pkg/test/composer.json index 01aebb49e..ad9234cda 100644 --- a/pkg/test/composer.json +++ b/pkg/test/composer.json @@ -1,13 +1,24 @@ { "name": "enqueue/test", + "homepage": "https://enqueue.forma-pro.com/", "license": "MIT", + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "require": { + "enqueue/dsn": "^0.10" + }, "autoload": { "psr-4": { "Enqueue\\Test\\": "" } }, "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "0.2.x-dev" + "dev-master": "0.10.x-dev" } } } diff --git a/pkg/wamp/.gitattributes b/pkg/wamp/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/wamp/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/wamp/.github/workflows/ci.yml b/pkg/wamp/.github/workflows/ci.yml new file mode 100644 index 000000000..5448d7b1a --- /dev/null +++ b/pkg/wamp/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2'] + + name: PHP ${{ matrix.php }} tests + + steps: + - uses: actions/checkout@v2 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: "ramsey/composer-install@v1" + + - run: vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/wamp/.gitignore b/pkg/wamp/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/wamp/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/wamp/JsonSerializer.php b/pkg/wamp/JsonSerializer.php new file mode 100644 index 000000000..9a224fbb8 --- /dev/null +++ b/pkg/wamp/JsonSerializer.php @@ -0,0 +1,33 @@ + $message->getBody(), + 'properties' => $message->getProperties(), + 'headers' => $message->getHeaders(), + ]); + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return $json; + } + + public function toMessage(string $string): WampMessage + { + $data = json_decode($string, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(sprintf('The malformed json given. Error %s and message %s', json_last_error(), json_last_error_msg())); + } + + return new WampMessage($data['body'], $data['properties'], $data['headers']); + } +} diff --git a/pkg/wamp/LICENSE b/pkg/wamp/LICENSE new file mode 100644 index 000000000..7afbaa1ff --- /dev/null +++ b/pkg/wamp/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Forma-Pro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/wamp/README.md b/pkg/wamp/README.md new file mode 100644 index 000000000..ee0bcaa17 --- /dev/null +++ b/pkg/wamp/README.md @@ -0,0 +1,36 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Web Application Messaging Protocol (WAMP) Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://img.shields.io/github/actions/workflow/status/php-enqueue/wamp/ci.yml?branch=master)](https://github.com/php-enqueue/wamp/actions?query=workflow%3ACI) +[![Total Downloads](https://poser.pugx.org/enqueue/wamp/d/total.png)](https://packagist.org/packages/enqueue/wamp) +[![Latest Stable Version](https://poser.pugx.org/enqueue/wamp/version.png)](https://packagist.org/packages/enqueue/wamp) + +This is an implementation of [queue interop](https://github.com/queue-interop/queue-interop). It uses [Thruway](https://github.com/thruway/client) internally. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://php-enqueue.github.io/transport/wamp/) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## Developed by Forma-Pro + +Forma-Pro is a full stack development company which interests also spread to open source development. +Being a team of strong professionals we have an aim an ability to help community by developing cutting edge solutions in the areas of e-commerce, docker & microservice oriented architecture where we have accumulated a huge many-years experience. +Our main specialization is Symfony framework based solution, but we are always looking to the technologies that allow us to do our job the best way. We are committed to creating solutions that revolutionize the way how things are developed in aspects of architecture & scalability. + +If you have any questions and inquires about our open source development, this product particularly or any other matter feel free to contact at opensource@forma-pro.com + +## License + +It is released under the [MIT License](LICENSE). diff --git a/pkg/wamp/Serializer.php b/pkg/wamp/Serializer.php new file mode 100644 index 000000000..414fcf414 --- /dev/null +++ b/pkg/wamp/Serializer.php @@ -0,0 +1,12 @@ +serializer = $serializer; + } + + /** + * @return Serializer + */ + public function getSerializer() + { + return $this->serializer; + } +} diff --git a/pkg/wamp/Tests/Functional/WampConsumerTest.php b/pkg/wamp/Tests/Functional/WampConsumerTest.php new file mode 100644 index 000000000..bb2dd89a4 --- /dev/null +++ b/pkg/wamp/Tests/Functional/WampConsumerTest.php @@ -0,0 +1,69 @@ +buildWampContext(); + $topic = $context->createTopic('topic'); + $consumer = $context->createConsumer($topic); + $producer = $context->createProducer(); + $message = $context->createMessage('the body'); + + // init client + $consumer->receive(1); + + $consumer->getClient()->getLoop()->futureTick(function () use ($producer, $topic, $message) { + $producer->send($topic, $message); + }); + + $receivedMessage = $consumer->receive(100); + + $this->assertInstanceOf(WampMessage::class, $receivedMessage); + $this->assertSame('the body', $receivedMessage->getBody()); + } + + public function testShouldSendAndReceiveNoWaitMessage() + { + $context = $this->buildWampContext(); + $topic = $context->createTopic('topic'); + $consumer = $context->createConsumer($topic); + $producer = $context->createProducer(); + $message = $context->createMessage('the body'); + + // init client + $consumer->receive(1); + + $consumer->getClient()->getLoop()->futureTick(function () use ($producer, $topic, $message) { + $producer->send($topic, $message); + }); + + $receivedMessage = $consumer->receiveNoWait(); + + $this->assertInstanceOf(WampMessage::class, $receivedMessage); + $this->assertSame('the body', $receivedMessage->getBody()); + } +} diff --git a/pkg/wamp/Tests/Functional/WampSubscriptionConsumerTest.php b/pkg/wamp/Tests/Functional/WampSubscriptionConsumerTest.php new file mode 100644 index 000000000..e272f42ed --- /dev/null +++ b/pkg/wamp/Tests/Functional/WampSubscriptionConsumerTest.php @@ -0,0 +1,51 @@ +buildWampContext(); + $topic = $context->createTopic('topic'); + $consumer = $context->createSubscriptionConsumer(); + $producer = $context->createProducer(); + $message = $context->createMessage('the body'); + + $receivedMessage = null; + $consumer->subscribe($context->createConsumer($topic), function ($message) use (&$receivedMessage) { + $receivedMessage = $message; + + return false; + }); + + // init client + $consumer->consume(1); + + $consumer->getClient()->getLoop()->futureTick(function () use ($producer, $topic, $message) { + $producer->send($topic, $message); + }); + + $consumer->consume(100); + + $this->assertInstanceOf(WampMessage::class, $receivedMessage); + $this->assertSame('the body', $receivedMessage->getBody()); + } +} diff --git a/pkg/wamp/Tests/Spec/JsonSerializerTest.php b/pkg/wamp/Tests/Spec/JsonSerializerTest.php new file mode 100644 index 000000000..f062a7058 --- /dev/null +++ b/pkg/wamp/Tests/Spec/JsonSerializerTest.php @@ -0,0 +1,71 @@ +assertClassImplements(Serializer::class, JsonSerializer::class); + } + + public function testShouldConvertMessageToJsonString() + { + $serializer = new JsonSerializer(); + + $message = new WampMessage('theBody', ['aProp' => 'aPropVal'], ['aHeader' => 'aHeaderVal']); + + $json = $serializer->toString($message); + + $this->assertSame('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}', $json); + } + + public function testThrowIfFailedToEncodeMessageToJson() + { + $serializer = new JsonSerializer(); + + $resource = fopen(__FILE__, 'r'); + + // guard + $this->assertIsResource($resource); + + $message = new WampMessage('theBody', ['aProp' => $resource]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toString($message); + } + + public function testShouldConvertJsonStringToMessage() + { + $serializer = new JsonSerializer(); + + $message = $serializer->toMessage('{"body":"theBody","properties":{"aProp":"aPropVal"},"headers":{"aHeader":"aHeaderVal"}}'); + + $this->assertInstanceOf(WampMessage::class, $message); + + $this->assertSame('theBody', $message->getBody()); + $this->assertSame(['aProp' => 'aPropVal'], $message->getProperties()); + $this->assertSame(['aHeader' => 'aHeaderVal'], $message->getHeaders()); + } + + public function testThrowIfFailedToDecodeJsonToMessage() + { + $serializer = new JsonSerializer(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The malformed json given.'); + $serializer->toMessage('{]'); + } +} diff --git a/pkg/wamp/Tests/Spec/WampConnectionFactoryTest.php b/pkg/wamp/Tests/Spec/WampConnectionFactoryTest.php new file mode 100644 index 000000000..5b6e418c9 --- /dev/null +++ b/pkg/wamp/Tests/Spec/WampConnectionFactoryTest.php @@ -0,0 +1,17 @@ +buildWampContext(); + } +} diff --git a/pkg/wamp/Tests/Spec/WampMessageTest.php b/pkg/wamp/Tests/Spec/WampMessageTest.php new file mode 100644 index 000000000..3e030d8c1 --- /dev/null +++ b/pkg/wamp/Tests/Spec/WampMessageTest.php @@ -0,0 +1,17 @@ +buildWampContext()->createProducer(); + } +} diff --git a/pkg/wamp/Tests/Spec/WampQueueTest.php b/pkg/wamp/Tests/Spec/WampQueueTest.php new file mode 100644 index 000000000..015536fd1 --- /dev/null +++ b/pkg/wamp/Tests/Spec/WampQueueTest.php @@ -0,0 +1,17 @@ + 'wamp://127.0.0.1:9090', + * 'host' => '127.0.0.1', + * 'port' => '9090', + * 'max_retries' => 15, + * 'initial_retry_delay' => 1.5, + * 'max_retry_delay' => 300, + * 'retry_delay_growth' => 1.5, + * ] + * + * or + * + * wamp://127.0.0.1:9090?max_retries=10 + * + * @param array|string|null $config + */ + public function __construct($config = 'wamp:') + { + if (empty($config)) { + $config = $this->parseDsn('wamp:'); + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + $config = empty($config['dsn']) ? $config : $this->parseDsn($config['dsn']); + } else { + throw new \LogicException('The config must be either an array of options, a DSN string or null'); + } + + $config = array_replace([ + 'host' => '127.0.0.1', + 'port' => '9090', + 'max_retries' => 15, + 'initial_retry_delay' => 1.5, + 'max_retry_delay' => 300, + 'retry_delay_growth' => 1.5, + ], $config); + + $this->config = $config; + } + + public function createContext(): Context + { + return new WampContext(function () { + return $this->establishConnection(); + }); + } + + private function establishConnection(): Client + { + $uri = sprintf('ws://%s:%s', $this->config['host'], $this->config['port']); + + $client = new Client('realm1'); + $client->addTransportProvider(new PawlTransportProvider($uri)); + $client->setReconnectOptions([ + 'max_retries' => $this->config['max_retries'], + 'initial_retry_delay' => $this->config['initial_retry_delay'], + 'max_retry_delay' => $this->config['max_retry_delay'], + 'retry_delay_growth' => $this->config['retry_delay_growth'], + ]); + + return $client; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if (false === in_array($dsn->getSchemeProtocol(), ['wamp', 'ws'], true)) { + throw new \LogicException(sprintf('The given scheme protocol "%s" is not supported. It must be "wamp"', $dsn->getSchemeProtocol())); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'host' => $dsn->getHost(), + 'port' => $dsn->getPort(), + 'max_retries' => $dsn->getDecimal('max_retries'), + 'initial_retry_delay' => $dsn->getFloat('initial_retry_delay'), + 'max_retry_delay' => $dsn->getDecimal('max_retry_delay'), + 'retry_delay_growth' => $dsn->getFloat('retry_delay_growth'), + ]), function ($value) { return null !== $value; }); + } +} diff --git a/pkg/wamp/WampConsumer.php b/pkg/wamp/WampConsumer.php new file mode 100644 index 000000000..8a6733e36 --- /dev/null +++ b/pkg/wamp/WampConsumer.php @@ -0,0 +1,135 @@ +context = $context; + $this->queue = $destination; + } + + public function getQueue(): Queue + { + return $this->queue; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function receive(int $timeout = 0): ?Message + { + $init = false; + $this->timer = null; + $this->message = null; + + if (null === $this->client) { + $init = true; + + $this->client = $this->context->getNewClient(); + $this->client->setAttemptRetry(true); + $this->client->on('open', function (ClientSession $session) { + $session->subscribe($this->queue->getQueueName(), function ($args) { + $this->message = $this->context->getSerializer()->toMessage($args[0]); + + $this->client->emit('do-stop'); + }); + }); + + $this->client->on('do-stop', function () { + if ($this->timer) { + $this->client->getLoop()->cancelTimer($this->timer); + } + + $this->client->getLoop()->stop(); + }); + } + + if ($timeout > 0) { + $timeout /= 1000; + $timeout = $timeout >= 0.1 ? $timeout : 0.1; + + $this->timer = $this->client->getLoop()->addTimer($timeout, function () { + $this->client->emit('do-stop'); + }); + } + + if ($init) { + $this->client->start(false); + } + + $this->client->getLoop()->run(); + + $message = $this->message; + + $this->timer = null; + $this->message = null; + + return $message; + } + + public function receiveNoWait(): ?Message + { + return $this->receive(100); + } + + /** + * @param WampMessage $message + */ + public function acknowledge(Message $message): void + { + // do nothing. wamp transport always works in auto ack mode + } + + /** + * @param WampMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, WampMessage::class); + + // do nothing on reject. wamp transport always works in auto ack mode + + if ($requeue) { + $this->context->createProducer()->send($this->queue, $message); + } + } +} diff --git a/pkg/wamp/WampContext.php b/pkg/wamp/WampContext.php new file mode 100644 index 000000000..623aa33f9 --- /dev/null +++ b/pkg/wamp/WampContext.php @@ -0,0 +1,107 @@ +clientFactory = $clientFactory; + + $this->setSerializer(new JsonSerializer()); + } + + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new WampMessage($body, $properties, $headers); + } + + public function createTopic(string $topicName): Topic + { + return new WampDestination($topicName); + } + + public function createQueue(string $queueName): Queue + { + return new WampDestination($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function createProducer(): Producer + { + return new WampProducer($this); + } + + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, WampDestination::class); + + return new WampConsumer($this, $destination); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new WampSubscriptionConsumer($this); + } + + public function purgeQueue(Queue $queue): void + { + throw PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function close(): void + { + foreach ($this->clients as $client) { + if (null === $client->getSession()) { + return; + } + + $client->setAttemptRetry(false); + $client->getSession()->close(); + } + } + + public function getNewClient(): Client + { + $client = call_user_func($this->clientFactory); + + if (false == $client instanceof Client) { + throw new \LogicException(sprintf('The factory must return instance of "%s". But it returns %s', Client::class, is_object($client) ? $client::class : gettype($client))); + } + + $this->clients[] = $client; + + return $client; + } +} diff --git a/pkg/wamp/WampDestination.php b/pkg/wamp/WampDestination.php new file mode 100644 index 000000000..a99bc1fa0 --- /dev/null +++ b/pkg/wamp/WampDestination.php @@ -0,0 +1,31 @@ +name = $name; + } + + public function getQueueName(): string + { + return $this->name; + } + + public function getTopicName(): string + { + return $this->name; + } +} diff --git a/pkg/wamp/WampMessage.php b/pkg/wamp/WampMessage.php new file mode 100644 index 000000000..9ad41f5a4 --- /dev/null +++ b/pkg/wamp/WampMessage.php @@ -0,0 +1,21 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } +} diff --git a/pkg/wamp/WampProducer.php b/pkg/wamp/WampProducer.php new file mode 100644 index 000000000..71ea625ae --- /dev/null +++ b/pkg/wamp/WampProducer.php @@ -0,0 +1,182 @@ +context = $context; + } + + /** + * @param WampDestination $destination + * @param WampMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, WampDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, WampMessage::class); + + $init = false; + $this->message = $message; + $this->destination = $destination; + + if (null === $this->client) { + $init = true; + + $this->client = $this->context->getNewClient(); + $this->client->setAttemptRetry(true); + $this->client->on('open', function (ClientSession $session) { + $this->session = $session; + + $this->doSendMessageIfPossible(); + }); + + $this->client->on('close', function () { + if ($this->session === $this->client->getSession()) { + $this->session = null; + } + }); + + $this->client->on('error', function () { + if ($this->session === $this->client->getSession()) { + $this->session = null; + } + }); + + $this->client->on('do-send', function (WampDestination $destination, WampMessage $message) { + $onFinish = function () { + $this->client->emit('do-stop'); + }; + + $payload = $this->context->getSerializer()->toString($message); + + $this->session->publish($destination->getTopicName(), [$payload], [], ['acknowledge' => true]) + ->then($onFinish, $onFinish); + }); + + $this->client->on('do-stop', function () { + $this->client->getLoop()->stop(); + }); + } + + $this->client->getLoop()->futureTick(function () { + $this->doSendMessageIfPossible(); + }); + + if ($init) { + $this->client->start(false); + } + + $this->client->getLoop()->run(); + } + + /** + * @return WampProducer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + throw DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + public function getDeliveryDelay(): ?int + { + return null; + } + + /** + * @return WampProducer + */ + public function setPriority(?int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @return WampProducer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw TimeToLiveNotSupportedException::providerDoestNotSupportIt(); + } + + public function getTimeToLive(): ?int + { + return null; + } + + private function doSendMessageIfPossible() + { + if (null === $this->session) { + return; + } + + if (null === $this->message) { + return; + } + + $message = $this->message; + $destination = $this->destination; + + $this->message = null; + $this->destination = null; + + $this->client->emit('do-send', [$destination, $message]); + } +} diff --git a/pkg/wamp/WampSubscriptionConsumer.php b/pkg/wamp/WampSubscriptionConsumer.php new file mode 100644 index 000000000..2d25a673b --- /dev/null +++ b/pkg/wamp/WampSubscriptionConsumer.php @@ -0,0 +1,161 @@ +context = $context; + $this->subscribers = []; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function consume(int $timeout = 0): void + { + if (empty($this->subscribers)) { + throw new \LogicException('There is no subscribers. Consider calling basicConsumeSubscribe before consuming'); + } + + $init = false; + + if (null === $this->client) { + $init = true; + + $this->client = $this->context->getNewClient(); + $this->client->setAttemptRetry(true); + $this->client->on('open', function (ClientSession $session) { + foreach ($this->subscribers as $queue => $subscriber) { + $session->subscribe($queue, function ($args) use ($subscriber) { + $message = $this->context->getSerializer()->toMessage($args[0]); + + /** @var WampConsumer $consumer */ + /** @var callable $callback */ + list($consumer, $callback) = $subscriber; + + if (false === call_user_func($callback, $message, $consumer)) { + $this->client->emit('do-stop'); + } + }); + } + }); + + $this->client->on('do-stop', function () { + if ($this->timer) { + $this->client->getLoop()->cancelTimer($this->timer); + } + + $this->client->getLoop()->stop(); + }); + } + + if ($timeout > 0) { + $timeout /= 1000; + $timeout = $timeout >= 0.1 ? $timeout : 0.1; + + $this->timer = $this->client->getLoop()->addTimer($timeout, function () { + $this->client->emit('do-stop'); + }); + } + + if ($init) { + $this->client->start(false); + } + + $this->client->getLoop()->run(); + } + + /** + * @param WampConsumer $consumer + */ + public function subscribe(Consumer $consumer, callable $callback): void + { + if (false == $consumer instanceof WampConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', WampConsumer::class, $consumer::class)); + } + + if ($this->client) { + throw new \LogicException('Could not subscribe after consume was called'); + } + + $queueName = $consumer->getQueue()->getQueueName(); + if (array_key_exists($queueName, $this->subscribers)) { + if ($this->subscribers[$queueName][0] === $consumer && $this->subscribers[$queueName][1] === $callback) { + return; + } + + throw new \InvalidArgumentException(sprintf('There is a consumer subscribed to queue: "%s"', $queueName)); + } + + $this->subscribers[$queueName] = [$consumer, $callback]; + } + + /** + * @param WampConsumer $consumer + */ + public function unsubscribe(Consumer $consumer): void + { + if (false == $consumer instanceof WampConsumer) { + throw new \InvalidArgumentException(sprintf('The consumer must be instance of "%s" got "%s"', WampConsumer::class, $consumer::class)); + } + + if ($this->client) { + throw new \LogicException('Could not unsubscribe after consume was called'); + } + + $queueName = $consumer->getQueue()->getQueueName(); + + if (false == array_key_exists($queueName, $this->subscribers)) { + return; + } + + if ($this->subscribers[$queueName][0] !== $consumer) { + return; + } + + unset($this->subscribers[$queueName]); + } + + public function unsubscribeAll(): void + { + if ($this->client) { + throw new \LogicException('Could not unsubscribe after consume was called'); + } + + $this->subscribers = []; + } +} diff --git a/pkg/wamp/composer.json b/pkg/wamp/composer.json new file mode 100644 index 000000000..b510627bd --- /dev/null +++ b/pkg/wamp/composer.json @@ -0,0 +1,47 @@ +{ + "name": "enqueue/wamp", + "type": "library", + "description": "The Web Application Messaging Protocol Transport", + "keywords": ["messaging", "queue", "wamp", "thruway"], + "homepage": "https://enqueue.forma-pro.com/", + "license": "MIT", + "require": { + "php": "^8.1", + "queue-interop/queue-interop": "^0.8.1", + "enqueue/dsn": "^0.10.8", + "thruway/client": "^0.5.5", + "thruway/pawl-transport": "^0.5.1", + "voryx/thruway-common": "^1.0.1", + "react/dns": "^1.4", + "react/event-loop": "^1.2", + "react/promise": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "enqueue/test": "0.10.x-dev", + "enqueue/null": "0.10.x-dev", + "queue-interop/queue-spec": "^0.6.2" + }, + "support": { + "email": "opensource@forma-pro.com", + "issues": "https://github.com/php-enqueue/enqueue-dev/issues", + "forum": "https://gitter.im/php-enqueue/Lobby", + "source": "https://github.com/php-enqueue/enqueue-dev", + "docs": "https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md" + }, + "autoload": { + "psr-4": { "Enqueue\\Wamp\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "beta", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + }, + "config": { + "prefer-stable": true + } +} diff --git a/pkg/wamp/phpunit.xml.dist b/pkg/wamp/phpunit.xml.dist new file mode 100644 index 000000000..9e8558ce8 --- /dev/null +++ b/pkg/wamp/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/var/rabbitmq_certificates/cacert.pem b/var/rabbitmq_certificates/cacert.pem new file mode 100644 index 000000000..5ca00efcd --- /dev/null +++ b/var/rabbitmq_certificates/cacert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICxjCCAa6gAwIBAgIJALosbIbfKPgyMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV +BAMMCE15VGVzdENBMB4XDTE3MTEwMTExNTMwM1oXDTE4MTEwMTExNTMwM1owEzER +MA8GA1UEAwwITXlUZXN0Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDfD1uO8U5eMa7JATHkQ+egMy4fCBSu55K4NGxj46OscK8xz897es3kvgwcvHJh +/UWH4cx7ge2bKh94ESniEa9dYuIrqg4uTjaKDB86tcJ6M0JYXo84yplBEf7rnfb7 +Femerm28PGQ87hw1Bi3JSKocXVgO6TTEGieOeezcp6pPy4HmLonALsROenF9bXhy +Z3bJAJAE+/1c15i42dSmTbkeV2l/8z740mI+uM9lKUYgxksZWscqcH2i+VptPRQl +z+K8dCYFlO2GGOqTpTjVoB/2p5hx4zJzM/NVMekwtgNnEIpUYKer4bleL3KO1LYM +RvwFQeEO9N6Qiq/X5/UzDMLZAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0P +BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQCiItTdQ//9XlRauj5j9kMIbXngKV2k +5ND9im2rhWFMIoyOJ95dmoeY9bzqFMLm/3S0JygnUKWk9TiDTxrJkNDm7eBM6F3z +0d6062k90qpY2dVdeEdab3whOkqJXFNnnUH2ey44yqmmpGe6751b5eA7obiIP8F8 +mqN3Mh+6axlzY1W/Fi/qC/PtCzIMr8tGcgeWo9YfwjQ2GpEnYdw3iVct1pepGgUr +X4J5lRrdQlneOixnf5fp2jxi0E6bmOqW03LcNXlnM1lhAjlQxTOvMPg762ZyT8Ir +rr+EBceAzKZFZSyRr0mgQzeMjCQMWIeTMocHRGE0pXvswBSoM3OKuZ/5 +-----END CERTIFICATE----- diff --git a/var/rabbitmq_certificates/cert.pem b/var/rabbitmq_certificates/cert.pem new file mode 100644 index 000000000..12b395936 --- /dev/null +++ b/var/rabbitmq_certificates/cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIBBDANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhNeVRl +c3RDQTAeFw0xODExMDUxMDMyMjdaFw0yODExMDIxMDMyMjdaMCgxFTATBgNVBAMM +DDE4OGUzNGFjNWQ4MTEPMA0GA1UECgwGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA2pgaF9Dk7YtbE2/LJKnpDzwTeSWuvplVuysNW70P1AiD +9LMplvkYG1Jbmr8Tfd15LkkZi/IQroISuqFhol0IvflFT8t1Q5O/9rGFpRLujyiI +bidBaTszFNFPNjhPGyyw2eEQEHNzeAFpOdA0kWdYap3OI7DDdleU8SOLt4bLlwoD +ZC9S2RSpycFJL7JzfnjwkvtH3GVH2WYCukFYJsQGpQ/LbCHZ5ut+2cvM4BeKPH2B +YdfCRrrPsRxO9GDh1SW1uMaX4d4AsyM5twh+GCPMBDu5dQh4d7hqxN4hcqFr7mzp +m5H84x3CrAnuDo6Q8vyVv/3VzxnobptCz3nksQxIRQIDAQABoy8wLTAJBgNVHRME +AjAAMAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjANBgkqhkiG9w0B +AQsFAAOCAQEA16hw/R7sZ5d0ykwKFzcjYSN+Dl8mnka1PRfVGqxFluahytbvD3Ns +yzhdXD4iaTumuVqjyOSe7WfA4hE1Nb1aW2H6GhEzqDsFo/usmC6H6zH9HXVtI9wM +l7sa7THWh1BTNssRBtihpjhjlWzU6eQd7F7O3rhPoM0FeZ7S78ZCo8R86p72xaKg +Ttx+CNfUyvDEY35Jh5kYg/VW0V1/M6zsif0xEbSYTmEkirS4exdrk8Hy5q70V8ZN +UEHxXOcQRN+Y+SP8xFUWNjpRO/+P+ZapvXUd47FnZ12Hxyp9V+V1yqO24mv4RzpO +AMpTn8botOTbuvQXqzr398uOwxZ7RlTzIg== +-----END CERTIFICATE----- diff --git a/var/rabbitmq_certificates/key.pem b/var/rabbitmq_certificates/key.pem new file mode 100644 index 000000000..8ec88ecd8 --- /dev/null +++ b/var/rabbitmq_certificates/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA2pgaF9Dk7YtbE2/LJKnpDzwTeSWuvplVuysNW70P1AiD9LMp +lvkYG1Jbmr8Tfd15LkkZi/IQroISuqFhol0IvflFT8t1Q5O/9rGFpRLujyiIbidB +aTszFNFPNjhPGyyw2eEQEHNzeAFpOdA0kWdYap3OI7DDdleU8SOLt4bLlwoDZC9S +2RSpycFJL7JzfnjwkvtH3GVH2WYCukFYJsQGpQ/LbCHZ5ut+2cvM4BeKPH2BYdfC +RrrPsRxO9GDh1SW1uMaX4d4AsyM5twh+GCPMBDu5dQh4d7hqxN4hcqFr7mzpm5H8 +4x3CrAnuDo6Q8vyVv/3VzxnobptCz3nksQxIRQIDAQABAoIBAFPcAjacxxZyXdBJ +FQ/Nt0FG9NmHIVCxlnglfgxxrX7UfXsEuLHYge0JXWcyYpHowzKEjK5pgQjRkcnT +W5dkRZRL6tE/5o60QfKsC+9WIfr9u8k2ehuawG/+FHtigsaUEIylkPoesG+bavjo +7SHTGdJdE9YRXAssclFIJ7OSnMwmL6Q3l4KbZd4Gwz7h16kT3tiW1djI+Dt3POiX +14/3HDXq69eoYwvpGuxyU4K2e9GpiCmYspF1rt9s9row2OUjYevl3Mdr/3YjiDo7 +U9NCDVhzl2AfxpfFI9EMHAQuUQYb1lU4L4OCmEUs20ZjfYSSTrl4vDNumOgi0sDX +snZKJsECgYEA9R01tSi4j+BmcZK1UPgxRyvCxen087j0hfn5GoqlF0z85RFgseIu +hZ6jKWZtrUHN7yOIogUwyYRHDtIe/xChTdp/fFw56qQfIKR7pqScw+X/GglMLbRc +2qjnT8/imxaE2mfghnJ1Ts0Elc4ZW7H2W8up18LutKDCX/McuaqXstkCgYEA5E1g +lK+ToUI9HiN2uYl7Xhda5gVuLhTsKIFuIxjyhL8lg+hN/SYIRbldOD8YhH6nPnkx +qDofvUJw9GMaCsigwzBzNbKOiB4tZKBkavbrXFHKLU1R4ZDLtqgxlFtDYBTt+xyn +54GEwZV0FNVK/mRRRq8yWpTTXhflCN0xvO+PRU0CgYAKJKNMU9sPWSHkIUYPi7W+ +VDlDJ2NTkpvLz4RXbNVYGX99mzJ9Kfby4JWv6OUw/kAfXUESM1TJggfOvTM7Kt0B +88DCzK4434HKQAQ96SHzmVjtIuVcHtKY2dR4oQmnkU7+Gr5X0fS4xhMif9zcxoiD +U/I7U329S8m/XrgZls2gQQKBgB4a3ft9U5hWJb0NrCA3Mt9rcP8YBDlrZODKgH18 +Uq6Sjh3gyjfxhfG7ycEbAN6n3OHuFVA7qefJFSAE2XBGmHxkrSyNiSIF6LJ2PAem +285msqRap8t4zoQdlbwcdLv8xozwcGuktp7YWGBO5/63t8f8XkV3jo+/0uHiWSay +6E+ZAoGBAOk0eYTLurIylLQH0uEuy55wY4u79mb2ukFSQqa6iXU55ZExXd8Dphqu +nDy1NuxoCR8fbfhtr7Ea0RhKJJKqBNhK6r48ZgYsSwFsXPDvlAstnT7ZYe2tyszg +HBby3tmlpiNLFrR9e7RMpWxB+7Myu8bWPbLI1WcbbLkTbA63XKqy +-----END RSA PRIVATE KEY----- diff --git a/var/rabbitmq_certificates/req.pem b/var/rabbitmq_certificates/req.pem new file mode 100644 index 000000000..1bf7624c2 --- /dev/null +++ b/var/rabbitmq_certificates/req.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICbTCCAVUCAQAwKDEVMBMGA1UEAwwMY2I3MDRiZGFhZmM2MQ8wDQYDVQQKDAZj +bGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEu+DlGZQ7daz7 +0PReHWXSTkeDfSNRZzJXicJfbAmDHPmaMCmwRipLK0TdKvrc62X7JS6CgvyA907t +5ayVYnJwhYoURY6YTsbdXz2SMB3+w09TpajaPhP2kJEuiA66rnhI8QR2fYcY+v4l +pRhG92xdmYvLpZrV2fHdR6bEqKsqmai7GSpN/JxkUqrxFCw6syoy1kUTGA377KU0 +wNyXutgbdxh24o324GrkXByXmhqfvp829Odb75uiVI5VrrApN8+31r5dywDHH5dK +tbHXsdp6CtMUyLMXFNY7Cp81UJOYmQOsjgZihq3XKYeVvm/QxBFoxJaL5RIuasog +RSpdvAcnAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAEKbGFeikT3sY2TiC+CbS +dXoABRRoXrqNk1nj1UF5owRWQPWr38rXANyg6GJwbyoWMjthWyPNLL5ElpxkEMg+ +9g4jXLHzmLRrgnJM0yHP6d6/QocKpGgonJdAwsZ51CZ9nEt8F4pkCG99e+wHduta +TZireNCA71MG+AzmctgQAFINjQpJRC9YxlIPzRGXZFambL7FosxMwzSsFZ25hnC+ +T2QE59nC3t/8y+cdv7quG4vULI8JQAvVtm1PzKam92/R4/jg36PIm+JOK/Aqgrr4 +/2Kngj4HpurJZI34U0Pym2ZVe7PWtfyt0XqM1t95U08Nfgt4B9NLoub+1Ez+8QIN +TA== +-----END CERTIFICATE REQUEST-----