├── .env ├── .env.test ├── .github └── workflows │ └── php.yml ├── .gitignore ├── .scrutinizer.yml ├── README.md ├── bin └── mercure ├── codecov.yml ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── cache.yaml │ ├── framework.yaml │ └── test │ │ └── framework.yaml ├── services.yaml └── services_test.yaml ├── phpunit.xml.dist ├── src ├── Command │ ├── DebugConfigCommand.php │ ├── GenerateJWTCommand.php │ ├── ServeCommand.php │ └── StressSubscribersCommand.php ├── Configuration │ ├── Configuration.php │ └── WithConfigTrait.php ├── Controller │ ├── AbstractController.php │ ├── HealthController.php │ ├── PublishController.php │ └── SubscribeController.php ├── Exception │ └── Http │ │ ├── AccessDeniedHttpException.php │ │ ├── BadRequestHttpException.php │ │ ├── HttpException.php │ │ └── NotFoundHttpException.php ├── Helpers │ ├── QueryStringParser.php │ └── RedisHelper.php ├── Hub │ ├── Hub.php │ ├── HubFactory.php │ ├── HubFactoryInterface.php │ ├── HubInterface.php │ └── RequestHandler.php ├── Kernel.php ├── Metrics │ ├── MetricsHandlerFactory.php │ ├── MetricsHandlerFactoryInterface.php │ ├── MetricsHandlerInterface.php │ ├── PHP │ │ ├── PHPMetricsHandler.php │ │ └── PHPMetricsHandlerFactory.php │ └── Redis │ │ ├── RedisMetricsHandler.php │ │ └── RedisMetricsHandlerFactory.php ├── Model │ └── Message.php ├── Security │ ├── Authenticator.php │ ├── CORS.php │ └── TopicMatcher.php ├── Storage │ ├── NullStorage │ │ ├── NullStorage.php │ │ └── NullStorageFactory.php │ ├── PHP │ │ ├── PHPStorage.php │ │ └── PHPStorageFactory.php │ ├── Redis │ │ ├── RedisStorage.php │ │ └── RedisStorageFactory.php │ ├── StorageFactory.php │ ├── StorageFactoryInterface.php │ └── StorageInterface.php ├── Transport │ ├── PHP │ │ ├── PHPTransport.php │ │ └── PHPTransportFactory.php │ ├── Redis │ │ ├── RedisTransport.php │ │ └── RedisTransportFactory.php │ ├── TransportFactory.php │ ├── TransportFactoryInterface.php │ └── TransportInterface.php └── functions.php ├── symfony.lock └── tests ├── Classes ├── FilterIterator.php ├── NullTransport.php ├── NullTransportFactory.php └── ServicesByTagLocator.php ├── Helpers.php ├── Integration ├── MissedEvents │ └── MissedEventsTest.php └── PubSub │ └── PubSubTest.php ├── Unit ├── Command │ ├── GenerateJWTCommandTest.php │ └── ServeCommandTest.php ├── Configuration │ └── ConfigurationTest.php ├── Controller │ ├── HealthControllerTest.php │ ├── Publish │ │ └── PublishControllerTest.php │ └── Subscribe │ │ └── SubscribeControllerTest.php ├── Helpers │ └── QueryStringParsertest.php ├── Hub │ ├── HubFactoryTest.php │ ├── HubTest.php │ └── RequestHandlerTest.php ├── Model │ └── MessageTest.php ├── Security │ ├── AuthenticatorTest.php │ ├── CORSTest.php │ └── TopicMatcher │ │ ├── Publish │ │ └── TopicMatcherTest.php │ │ └── Subscribe │ │ └── TopicMatcherTest.php ├── Storage │ ├── PHP │ │ ├── PHPStorageFactoryTest.php │ │ └── PHPStorageTest.php │ └── Redis │ │ ├── RedisStorageFactoryTest.php │ │ └── RedisStorageTest.php └── Transport │ ├── PHP │ ├── PHPTransportFactoryTest.php │ └── PHPTransportTest.php │ └── Redis │ ├── RedisTransportFactoryTest.php │ └── RedisTransportTest.php └── bootstrap.php /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * /etc/mercure/mercure.env (if exists on the system) 5 | # * .env contains default values for the environment variables needed by the app 6 | # * .env.local uncommitted file with local overrides 7 | # * .env.$APP_ENV committed environment-specific defaults 8 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 9 | # 10 | # Real environment variables win over .env files. 11 | # 12 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=prod 19 | APP_SECRET=a966d63ed25c0a4e4bc89fc634f3894a 20 | ###< symfony/framework-bundle ### 21 | 22 | ADDR=$ADDR 23 | TRANSPORT_URL=$TRANSPORT_URL 24 | STORAGE_URL=$STORAGE_URL 25 | METRICS_URL=$METRICS_URL 26 | CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS 27 | PUBLISH_ALLOWED_ORIGINS=$PUBLISH_ALLOWED_ORIGINS 28 | JWT_KEY=$JWT_KEY 29 | JWT_ALGORITHM=$JWT_ALGORITHM 30 | PUBLISHER_JWT_KEY=$PUBLISHER_JWT_KEY 31 | PUBLISHER_JWT_ALGORITHM=$PUBLISHER_JWT_ALGORITHM 32 | SUBSCRIBER_JWT_KEY=$SUBSCRIBER_JWT_KEY 33 | SUBSCRIBER_JWT_ALGORITHM=$SUBSCRIBER_JWT_ALGORITHM 34 | ALLOW_ANONYMOUS=$ALLOW_ANONYMOUS 35 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | TRANSPORT_URL=php://localhost?size=1000 6 | REDIS_DSN=redis://localhost 7 | JWT_KEY=AVERYSECRETKEY 8 | ADDR=127.0.0.1:3000 9 | ALLOW_ANONYMOUS=1 10 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: CI Workflow 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | 9 | code-style: 10 | 11 | runs-on: ubuntu-20.04 12 | continue-on-error: ${{ matrix.experimental }} 13 | strategy: 14 | max-parallel: 10 15 | matrix: 16 | php: [ '7.4' ] 17 | experimental: [ false ] 18 | include: 19 | - php: '8.0' 20 | experimental: true 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | extensions: mbstring, pcntl 30 | coverage: pcov 31 | 32 | - name: Validate composer.json and composer.lock 33 | run: composer validate 34 | 35 | - name: Install dependencies 36 | run: composer install --prefer-dist --no-progress 37 | 38 | - name: Check code style 39 | run: composer phpcs:check 40 | 41 | - name: Track avoidable bugs 42 | run: composer phpstan:analyze 43 | 44 | integration-tests: 45 | 46 | runs-on: ubuntu-20.04 47 | continue-on-error: ${{ matrix.experimental }} 48 | strategy: 49 | max-parallel: 10 50 | matrix: 51 | php: [ '7.4' ] 52 | transport: [ 'php://localhost?size=1000', 'redis://localhost' ] 53 | experimental: [ false ] 54 | include: 55 | - php: '8.0' 56 | experimental: true 57 | 58 | steps: 59 | - uses: actions/checkout@v2 60 | 61 | - name: Setup PHP 62 | uses: shivammathur/setup-php@v2 63 | with: 64 | php-version: ${{ matrix.php }} 65 | extensions: mbstring, pcntl 66 | coverage: pcov 67 | 68 | - name: Setup a Redis server 69 | run: sudo apt install -y redis 70 | 71 | - name: Install dependencies 72 | run: composer install --prefer-dist --no-progress 73 | 74 | - name: Run integration tests 75 | run: php vendor/bin/pest --testsuite=Integration 76 | env: 77 | TRANSPORT_URL: ${{ matrix.transport }} 78 | 79 | unit-tests: 80 | 81 | runs-on: ubuntu-20.04 82 | continue-on-error: ${{ matrix.experimental }} 83 | strategy: 84 | max-parallel: 10 85 | matrix: 86 | php: [ '7.4' ] 87 | experimental: [ false ] 88 | include: 89 | - php: '8.0' 90 | experimental: true 91 | 92 | steps: 93 | - uses: actions/checkout@v2 94 | 95 | - name: Setup PHP 96 | uses: shivammathur/setup-php@v2 97 | with: 98 | php-version: ${{ matrix.php }} 99 | extensions: mbstring, pcntl 100 | coverage: pcov 101 | 102 | - name: Setup a Redis server 103 | run: sudo apt install -y redis 104 | 105 | - name: Install dependencies 106 | run: composer install --prefer-dist --no-progress 107 | 108 | - name: Run unit tests 109 | run: composer tests:unit:run:with-coverage 110 | 111 | - name: Upload coverage to Codecov 112 | uses: codecov/codecov-action@v1 113 | with: 114 | token: ${{ secrets.CODECOV_TOKEN }} 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /phpunit.xml 2 | /vendor 3 | 4 | ###> symfony/framework-bundle ### 5 | /.env.local 6 | /.env.local.php 7 | /.env.*.local 8 | /config/secrets/prod/prod.decrypt.private.php 9 | /public/bundles/ 10 | /src/.preload.php 11 | /var/ 12 | ###< symfony/framework-bundle ### 13 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | environment: 3 | php: '7.4' 4 | tests: 5 | override: 6 | command: "php -v" # Disable tests for Scrutinizer as they're handled by Github Actions 7 | 8 | checks: 9 | php: 10 | simplify_boolean_return: true 11 | return_doc_comment_if_not_inferrable: true 12 | properties_in_camelcaps: true 13 | parameters_in_camelcaps: true 14 | param_doc_comment_if_not_inferrable: true 15 | more_specific_types_in_doc_comments: true 16 | fix_use_statements: 17 | remove_unused: true 18 | fix_line_ending: true 19 | check_method_contracts: 20 | verify_interface_like_constraints: true 21 | verify_documented_constraints: true 22 | verify_parent_constraints: true 23 | 24 | coding_style: 25 | php: 26 | spaces: 27 | around_operators: 28 | concatenation: true 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI Workflow](https://github.com/bpolaszek/mercure-php-hub/workflows/CI%20Workflow/badge.svg) 2 | [![codecov](https://codecov.io/gh/bpolaszek/mercure-php-hub/branch/master/graph/badge.svg)](https://codecov.io/gh/bpolaszek/mercure-php-hub) 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/bpolaszek/mercure-php-hub/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/bpolaszek/mercure-php-hub/?branch=master) 4 | 5 | # Mercure PHP Hub 6 | 7 | This POC was a PHP implementation of the [Mercure Hub Specification](https://mercure.rocks/spec). 8 | 9 | This repository will no longer be maintained, as I recently released [Freddie](https://github.com/bpolaszek/freddie), which is a brand new implementation leveraging [Framework X](https://framework-x.org/) (still using ReactPHP under the hood). 10 | 11 | ## Installation 12 | 13 | PHP 7.4+ (and Redis, or a Redis instance, if using the Redis transport) is required to run the hub. 14 | 15 | ```bash 16 | composer create-project bentools/mercure-php-hub:dev-master 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```bash 22 | ./bin/mercure --jwt-key=\!ChangeMe\! 23 | ``` 24 | 25 | You can use environment variables (UPPER_SNAKE_CASE) to replace CLI options for better convenience. 26 | The hub will also check for the presence of a `/etc/mercure/mercure.env` file, 27 | then make use of the [Symfony DotEnv](https://github.com/symfony/dotenv) component to populate variables. 28 | 29 | Check out [configuration.php](src/Configuration/Configuration.php#L20) for full configuration options. 30 | 31 | 32 | ## Advantages and limitations 33 | 34 | This implementation does not provide SSL nor HTTP2 termination, so you'd better put a reverse proxy in front of it. 35 | 36 | Example with nginx: 37 | 38 | ```nginx 39 | upstream mercure { 40 | server 127.0.0.1:3000; 41 | } 42 | 43 | server { 44 | 45 | listen 443 ssl http2; 46 | listen [::]:443 ssl http2; 47 | server_name example.com; 48 | 49 | ssl_certificate /etc/ssl/certs/example.com/example.com.cert; 50 | ssl_certificate_key /etc/ssl/certs/example.com/example.com.key; 51 | ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; 52 | 53 | location /.well-known/mercure { 54 | proxy_pass http://mercure; 55 | proxy_read_timeout 24h; 56 | proxy_http_version 1.1; 57 | proxy_set_header Connection ""; 58 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 59 | proxy_set_header X-Forwarded-Host $host; 60 | proxy_set_header X-Forwarded-Proto $scheme; 61 | } 62 | } 63 | ``` 64 | 65 | By default, the hub will run as a simple event-dispatcher. It can fit common needs for a basic usage, but is not 66 | scalable (opening another process won't share the same event emitter). 67 | 68 | On the other hand, you can launch the hub on **multiple ports** and/or **multiple servers** with a Redis transport 69 | (as soon as they share the same Redis instance) and leverage a load-balancer to distribute the traffic. 70 | This is currently not possible with the open-source Go implementation of the hub because of concurrency restrictions 71 | on the _bolt_ transport. 72 | 73 | ```nginx 74 | upstream mercure { 75 | # 4 instances on 10.1.2.3 76 | server 10.1.2.3:3000; 77 | server 10.1.2.3:3001; 78 | server 10.1.2.3:3002; 79 | server 10.1.2.3:3003; 80 | 81 | # 4 instances on 10.1.2.4 82 | server 10.1.2.4:3000; 83 | server 10.1.2.4:3001; 84 | server 10.1.2.4:3002; 85 | server 10.1.2.4:3003; 86 | } 87 | ``` 88 | 89 | ```bash 90 | ./bin/mercure --transport-url="redis://localhost" --jwt-key=\!ChangeMe\! 91 | ``` 92 | 93 | ### Benchmarks 94 | 95 | Simulated 1 / 100 /1000 subscribers on server A, 1 publisher blasting messages on server B, Hub on server C (and D for the latter). 96 | 97 | | Implementation | Transport | Servers | Nodes | 1 subscriber | 100 subscribers | 1000 subscribers | 98 | | ----------------------- | -------------- | ------- | ----- | ------------ | --------------- | ---------------- | 99 | | Mercure.rocks GO Hub | Bolt | 1 | 1 | 361 / 286 | 129 / 4989 | 142 / 682 | 100 | | ReactPHP implementation | PHP | 1 | 1 | 860 / 295 | 519 / 4526 | 45 / 322 | 101 | | ReactPHP implementation | Redis (local) | 1 | 1 | 1548 / 411 | 393 / 5861 | 112 / 777 | 102 | | ReactPHP implementation | Redis (local) | 1 | 4 | 108 / 76 | 61 / 2852 | 61 / 688 | 103 | | ReactPHP implementation | Redis (shared) | 2 | 8 | 3035 / 144 | 1183 / 7864 | 708 / 2698 | 104 | 105 | Units are `POSTs (publish) / s` / `Received events / s` (total for all subscribers). 106 | 107 | Nodes are the total number of ReactPHP open ports. 108 | 109 | Hub was hosted on cheap server(s): 2GB / 2 CPU VPS (Ubuntu 20.04). 110 | You could probably reach a very high level of performance with better-sized servers and dedicated CPUs. 111 | 112 | ## Feature coverage 113 | 114 | | Feature | Covered | 115 | | ------- | ------- | 116 | | JWT through `Authorization` header | ✅ | 117 | | JWT through `mercureAuthorization` Cookie | ✅ | 118 | | Different JWTs for subscribers / publishers | ✅ | 119 | | Allow anonymous subscribers | ✅ | 120 | | CORS | ✅ | 121 | | Private updates | ✅ | 122 | | URI Templates for topics | ✅ | 123 | | Health check endpoint | ✅ | 124 | | HMAC SHA256 JWT signatures | ✅ | 125 | | RS512 JWT signatures | ✅ | 126 | | Environment variables configuration | ✅ | 127 | | Custom message IDs | ✅ | 128 | | Last event ID | ✅️ (except: `earliest` on REDIS transport) | 129 | | Customizable event type | ✅️ | 130 | | Customizable `retry` directive | ✅️ | 131 | | Logging | ❌ (WIP)️ | 132 | | Metrics | ❌ (WIP)️ | 133 | | Subscription events | ❌️ | 134 | | Subscription API | ❌️ | 135 | | Configuration w/ config file | ❌️ | 136 | | Payload | ❌️ | 137 | | Heartbeat | ❌️ | 138 | | `Forwarded` / `X-Forwarded-For` headers | ❌️ | 139 | | Alternate topics | ❌️ | 140 | 141 | ## Additional features 142 | 143 | This implementation provides features which are not defined in the original specification. 144 | 145 | ### Subscribe / Publish topic exclusions 146 | 147 | Mercure leverages [URI Templates](https://tools.ietf.org/html/rfc6570) to grant subscribe and/or publish auhorizations 148 | on an URI pattern basis: 149 | ```json 150 | { 151 | "mercure": { 152 | "publish": [ 153 | "https://example.com/items/{id}" 154 | ], 155 | "subscribe": [ 156 | "https://example.com/items/{id}" 157 | ] 158 | } 159 | } 160 | ``` 161 | 162 | However, denying access to a specific URL matching an URI template requires you to explicitely list authorized items: 163 | ```json 164 | { 165 | "mercure": { 166 | "publish": [ 167 | "https://example.com/items/1", 168 | "https://example.com/items/2", 169 | "https://example.com/items/4" 170 | ], 171 | "subscribe": [ 172 | "https://example.com/items/1", 173 | "https://example.com/items/2", 174 | "https://example.com/items/4" 175 | ] 176 | } 177 | } 178 | ``` 179 | 180 | When dealing with thousands of possibilities, it can quicky become a problem. The Mercure PHP Hub allows you to specify 181 | denylists through the `publish_exclude` and `subscribe_exclude` keys, which accept any topic selector: 182 | ```json 183 | { 184 | "mercure": { 185 | "publish": [ 186 | "https://example.com/items/{id}" 187 | ], 188 | "publish_exclude": [ 189 | "https://example.com/items/3" 190 | ], 191 | "subscribe": [ 192 | "https://example.com/items/{id}" 193 | ], 194 | "subscribe_exclude": [ 195 | "https://example.com/items/3" 196 | ] 197 | } 198 | } 199 | ``` 200 | 201 | ### Json Web Token Generator 202 | 203 | You can generate a JWT to use on the hub from the command-line: 204 | 205 | ```bash 206 | ./bin/mercure jwt:generate 207 | ``` 208 | 209 | It will ask you interactively what topic selectors you want to allow/deny for publishing/subscribing, and ask you 210 | for an optional TTL. 211 | 212 | If you want a raw output (to pipe the generated JWT for instance), use the `--raw` option. 213 | 214 | To disable interaction, you can use the following example: 215 | 216 | 217 | ```bash 218 | ./bin/mercure jwt:generate --no-interactive --publish=/foo/{id} --publish=/bar --publish-exclude=/foo/bar 219 | ``` 220 | 221 | It will use your JWT keys environment variables, or you can use the `--jwt-key`, `--publisher-jwt-key`, `--subscriber-jwt-key` options. 222 | 223 | For a full list of available options, run this: 224 | 225 | ```bash 226 | ./bin/mercure jwt:generate -h 227 | ``` 228 | 229 | ## Tests 230 | 231 | This project is covered with [Pest](https://pestphp.com/) tests. 232 | Coverage has to be improved: feel free to contribute. 233 | 234 | ```bash 235 | composer tests:run 236 | ``` 237 | 238 | ## Contribute 239 | 240 | If you want to improve this project, feel free to submit PRs: 241 | 242 | - CI will yell if you don't follow [PSR-12 coding standards](https://www.php-fig.org/psr/psr-12/) 243 | - In the case of a new feature, it must come along with tests 244 | - [PHPStan](https://phpstan.org/) analysis must pass at level 5 245 | 246 | You can run `composer ci:check` before committing to ensure all CI requirements are successfully met. 247 | 248 | ## License 249 | 250 | GNU General Public License v3.0. 251 | -------------------------------------------------------------------------------- /bin/mercure: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 24 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 25 | } 26 | 27 | if ($input->hasParameterOption('--no-debug', true)) { 28 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 29 | } 30 | 31 | $dotenv = new Dotenv(); 32 | if (is_readable('/etc/mercure/mercure.env')) { 33 | $dotenv->load('/etc/mercure/mercure.env'); 34 | } 35 | 36 | $dotenv->bootEnv(dirname(__DIR__).'/.env'); 37 | 38 | if ($_SERVER['APP_DEBUG']) { 39 | umask(0000); 40 | 41 | if (class_exists(Debug::class)) { 42 | Debug::enable(); 43 | } 44 | } 45 | 46 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 47 | $application = new Application($kernel); 48 | $application->find('mercure:serve')->setName('serve')->setAliases(['serve']); 49 | $application->find('mercure:jwt:generate')->setName('jwt:generate')->setAliases(['jwt:generate']); 50 | $application->find('mercure:stress:subscribers')->setName('stress:subscribers')->setAliases(['stress:subscribers']); 51 | $application->setDefaultCommand('serve'); 52 | $application->run($input); 53 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "src/Command/StressSubscribersCommand.php" 3 | - "src/Metrics/*" 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bentools/mercure-php-hub", 3 | "type": "project", 4 | "description": "A PHP Implementation of the Mercure Hub protocol.", 5 | "authors": [{ 6 | "name": "Beno!t POLASZEK", 7 | "email": "bpolaszek@gmail.com" 8 | }], 9 | "license": "GPL-3.0-only", 10 | "autoload": { 11 | "psr-4": { 12 | "BenTools\\MercurePHP\\": "src" 13 | }, 14 | "files": [ 15 | "src/functions.php" 16 | ] 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "BenTools\\MercurePHP\\Tests\\": "tests" 21 | }, 22 | "files": [ 23 | "tests/Helpers.php" 24 | ] 25 | }, 26 | "require": { 27 | "php": ">=7.4", 28 | "ext-json": "*", 29 | "ext-mbstring": "*", 30 | "ext-pcntl": "*", 31 | "bentools/psr7-request-matcher": "^1.1", 32 | "bentools/querystring": "^1.0", 33 | "clue/block-react": "^1.4", 34 | "clue/redis-react": "^2.4", 35 | "lcobucci/jwt": "3.3.*", 36 | "predis/predis": "^1.1", 37 | "psr/http-server-middleware": "^1.0", 38 | "psr/log": "^1.1", 39 | "ramsey/uuid": "^4.0", 40 | "react/http": "^1.0.0", 41 | "rize/uri-template": "^0.3.2", 42 | "symfony/console": "^5.2", 43 | "symfony/dotenv": "^5.2", 44 | "symfony/flex": "^1.11.0", 45 | "symfony/framework-bundle": "^5.2", 46 | "symfony/string": "^5.2", 47 | "symfony/yaml": "^5.2" 48 | }, 49 | "require-dev": { 50 | "ext-dom": "*", 51 | "bentools/cartesian-product": "^1.3.1", 52 | "bentools/iterable-functions": "^1.4", 53 | "bentools/shh": "^1.0", 54 | "clue/reactphp-eventsource": "dev-master#e356b73fbf54a491c37d6b571ee5245206cbdc27", 55 | "friendsofphp/php-cs-fixer": "^2.16", 56 | "pestphp/pest": "^0.3.19", 57 | "phpstan/phpstan": "^0.12.25", 58 | "phpunit/phpunit": "^9.0", 59 | "ringcentral/psr7": "^1.3", 60 | "squizlabs/php_codesniffer": "^3.5", 61 | "symfony/http-client": "^5.2", 62 | "symfony/process": "^5.2", 63 | "symfony/var-dumper": "^5.2" 64 | }, 65 | "config": { 66 | "sort-packages": true 67 | }, 68 | "scripts": { 69 | "tests:run": "vendor/bin/pest", 70 | "tests:unit:run": "vendor/bin/pest --testsuite=Unit", 71 | "tests:unit:run:with-coverage": "vendor/bin/pest --testsuite=Unit --coverage-clover=coverage.xml --whitelist=src", 72 | "tests:integration:run": "vendor/bin/pest --testsuite=Integration", 73 | "phpcs:check": "vendor/bin/phpcs --standard=PSR12 -n bin src tests --ignore=tests/bootstrap.php", 74 | "phpcs:fix": "vendor/bin/phpcbf --standard=PSR12 -n bin src tests", 75 | "phpstan:analyze": "vendor/bin/phpstan analyze --level 5 src", 76 | "ci:check": [ 77 | "@phpcs:check", 78 | "@phpstan:analyze", 79 | "@tests:integration:run", 80 | "@tests:unit:run" 81 | ], 82 | "auto-scripts": { 83 | "cache:clear": "symfony-cmd", 84 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 85 | } 86 | }, 87 | "keywords": [ 88 | "mercure", 89 | "pubsub", 90 | "publish", 91 | "subscribe", 92 | "SSE", 93 | "server-sent events", 94 | "http", 95 | "reactphp", 96 | "async" 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | ]; 6 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | 5 | php_errors: 6 | log: true 7 | 8 | router: 9 | enabled: false 10 | -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 6 | parameters: 7 | default_addr: !php/const BenTools\MercurePHP\Configuration\Configuration::DEFAULT_ADDR 8 | default_transport_url: !php/const BenTools\MercurePHP\Configuration\Configuration::DEFAULT_TRANSPORT_URL 9 | default_jwt_algorithm: !php/const BenTools\MercurePHP\Configuration\Configuration::DEFAULT_JWT_ALGORITHM 10 | default_cors_allowed_origins: !php/const BenTools\MercurePHP\Configuration\Configuration::DEFAULT_CORS_ALLOWED_ORIGINS 11 | default_publish_allowed_origins: !php/const BenTools\MercurePHP\Configuration\Configuration::DEFAULT_PUBLISH_ALLOWED_ORIGINS 12 | addr: '%env(default:default_addr:string:ADDR)%' 13 | transport_url: '%env(default:default_transport_url:string:TRANSPORT_URL)%' 14 | storage_url: '%env(default::string:STORAGE_URL)%' 15 | metrics_url: '%env(default::string:METRICS_URL)%' 16 | cors_allowed_origins: '%env(default:default_cors_allowed_origins:string:CORS_ALLOWED_ORIGINS)%' 17 | publish_allowed_origins: '%env(default:default_publish_allowed_origins:string:PUBLISH_ALLOWED_ORIGINS)%' 18 | jwt_key: '%env(default::string:JWT_KEY)%' 19 | jwt_algorithm: '%env(default:default_jwt_algorithm:string:JWT_ALGORITHM)%' 20 | publisher_jwt_key: '%env(default::string:PUBLISHER_JWT_KEY)%' 21 | publisher_jwt_algorithm: '%env(default::string:PUBLISHER_JWT_ALGORITHM)%' 22 | subscriber_jwt_key: '%env(default::string:SUBSCRIBER_JWT_KEY)%' 23 | subscriber_jwt_algorithm: '%env(default::string:SUBSCRIBER_JWT_ALGORITHM)%' 24 | allow_anonymous: '%env(bool:ALLOW_ANONYMOUS)%' 25 | 26 | services: 27 | # default configuration for services in *this* file 28 | _defaults: 29 | autowire: true # Automatically injects dependencies in your services. 30 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 31 | 32 | _instanceof: 33 | BenTools\MercurePHP\Transport\TransportFactoryInterface: 34 | tags: ['mercure.transport'] 35 | BenTools\MercurePHP\Storage\StorageFactoryInterface: 36 | tags: ['mercure.storage'] 37 | BenTools\MercurePHP\Metrics\MetricsHandlerFactoryInterface: 38 | tags: ['mercure.metrics_handler'] 39 | BenTools\MercurePHP\Controller\AbstractController: 40 | tags: ['mercure.controller'] 41 | 42 | # makes classes in src/ available to be used as services 43 | # this creates a service per class whose id is the fully-qualified class name 44 | BenTools\MercurePHP\: 45 | resource: '../src/' 46 | exclude: 47 | - '../src/Kernel.php' 48 | - '../src/functions.php' 49 | - '../src/Security/Authenticator.php' 50 | - '../src/Model/' 51 | 52 | BenTools\MercurePHP\Configuration\Configuration: 53 | arguments: 54 | $config: 55 | addr: '%addr%' 56 | transport_url: '%transport_url%' 57 | storage_url: '%storage_url%' 58 | metrics_url: '%metrics_url%' 59 | cors_allowed_origins: '%cors_allowed_origins%' 60 | publish_allowed_origins: '%publish_allowed_origins%' 61 | jwt_key: '%jwt_key%' 62 | jwt_algorithm: '%jwt_algorithm%' 63 | publisher_jwt_key: '%publisher_jwt_key%' 64 | publisher_jwt_algorithm: '%publisher_jwt_algorithm%' 65 | subscriber_jwt_key: '%subscriber_jwt_key%' 66 | subscriber_jwt_algorithm: '%subscriber_jwt_algorithm%' 67 | allow_anonymous: '%allow_anonymous%' 68 | 69 | BenTools\MercurePHP\Transport\TransportFactoryInterface: '@BenTools\MercurePHP\Transport\TransportFactory' 70 | BenTools\MercurePHP\Storage\StorageFactoryInterface: '@BenTools\MercurePHP\Storage\StorageFactory' 71 | BenTools\MercurePHP\Metrics\MetricsHandlerFactoryInterface: '@BenTools\MercurePHP\Metrics\MetricsHandlerFactory' 72 | 73 | BenTools\MercurePHP\Transport\TransportFactory: 74 | arguments: 75 | $factories: !tagged_iterator mercure.transport 76 | 77 | BenTools\MercurePHP\Storage\StorageFactory: 78 | arguments: 79 | $factories: !tagged_iterator mercure.storage 80 | 81 | BenTools\MercurePHP\Metrics\MetricsHandlerFactory: 82 | arguments: 83 | $factories: !tagged_iterator mercure.metrics_handler 84 | 85 | bentools.mercure.subscriber_authenticator: 86 | class: BenTools\MercurePHP\Security\Authenticator 87 | factory: ['BenTools\MercurePHP\Security\Authenticator', 'createSubscriberAuthenticator'] 88 | 89 | bentools.mercure.publisher_authenticator: 90 | class: BenTools\MercurePHP\Security\Authenticator 91 | factory: ['BenTools\MercurePHP\Security\Authenticator', 'createPublisherAuthenticator'] 92 | 93 | BenTools\MercurePHP\Hub\HubFactoryInterface: '@BenTools\MercurePHP\Hub\HubFactory' 94 | BenTools\MercurePHP\Hub\HubFactory: 95 | arguments: 96 | $controllers: !tagged_iterator mercure.controller 97 | 98 | React\EventLoop\Factory: ~ 99 | 100 | React\EventLoop\LoopInterface: 101 | factory: ['@React\EventLoop\Factory', 'create'] 102 | -------------------------------------------------------------------------------- /config/services_test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | BenTools\MercurePHP\Tests\Classes\ServicesByTagLocator: 4 | public: true 5 | arguments: 6 | $services: 7 | mercure.controller: !tagged_iterator mercure.controller 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | ./tests/Unit 13 | 14 | 15 | ./tests/Integration 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Command/DebugConfigCommand.php: -------------------------------------------------------------------------------- 1 | params = $params; 38 | $this->addr = $addr; 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $io = new SymfonyStyle($input, $output); 44 | $keys = self::CONFIGURATION_KEYS; 45 | $rows = []; 46 | foreach ($keys as $key) { 47 | $value = $this->params->get($key); 48 | if (null === $value) { 49 | $value = 'null'; 50 | } 51 | if (\is_bool($value)) { 52 | $value = $value ? 'true' : 'false'; 53 | } 54 | if ('' === $value) { 55 | $value = '\'\''; 56 | } 57 | $rows[] = [$key, $value]; 58 | } 59 | 60 | $io->table(['Key', 'Value'], $rows); 61 | 62 | return self::SUCCESS; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Command/ServeCommand.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 39 | $this->factory = $factory; 40 | $this->loop = $loop; 41 | $this->logger = $logger; 42 | } 43 | 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $loop = $this->loop; 47 | $output = new SymfonyStyle($input, $output); 48 | $logger = $this->logger ?? new ConsoleLogger($output, [LogLevel::INFO => OutputInterface::VERBOSITY_NORMAL]); 49 | try { 50 | $config = $this->configuration->overrideWith(without_nullish_values($input->getOptions()))->asArray(); 51 | $factory = $this->factory->withConfig($config); 52 | $loop->futureTick( 53 | function () use ($config, $output) { 54 | $this->displayConfiguration($config, $output); 55 | } 56 | ); 57 | 58 | $hub = $factory->create(); 59 | $hub->run(); 60 | 61 | if (\SIGINT === $hub->getShutdownSignal()) { 62 | $output->newLine(2); 63 | $output->writeln('SIGINT received. 😢'); 64 | $output->writeln('Goodbye! 👋'); 65 | 66 | return 0; 67 | } 68 | 69 | $output->error('Server process was killed unexpectedly.'); 70 | 71 | return 1; 72 | } catch (\Exception $e) { 73 | $output->error($e->getMessage()); 74 | 75 | return 1; 76 | } 77 | } 78 | 79 | protected function configure(): void 80 | { 81 | $this->setDescription('Runs the Mercure Hub as a standalone application.'); 82 | $this->addOption( 83 | 'addr', 84 | null, 85 | InputOption::VALUE_OPTIONAL, 86 | 'The address to listen on.', 87 | ) 88 | ->addOption( 89 | 'transport-url', 90 | null, 91 | InputOption::VALUE_OPTIONAL, 92 | 'The DSN to transport messages.', 93 | ) 94 | ->addOption( 95 | 'storage-url', 96 | null, 97 | InputOption::VALUE_OPTIONAL, 98 | 'The DSN to store messages.', 99 | ) 100 | ->addOption( 101 | 'metrics-url', 102 | null, 103 | InputOption::VALUE_OPTIONAL, 104 | 'The DSN to store metrics.', 105 | ) 106 | ->addOption( 107 | 'cors-allowed-origins', 108 | null, 109 | InputOption::VALUE_OPTIONAL, 110 | 'A list of allowed CORS origins, can be * for all.', 111 | ) 112 | ->addOption( 113 | 'jwt-key', 114 | null, 115 | InputOption::VALUE_OPTIONAL, 116 | 'The JWT key to use for both publishers and subscribers', 117 | ) 118 | ->addOption( 119 | 'jwt-algorithm', 120 | null, 121 | InputOption::VALUE_OPTIONAL, 122 | 'The JWT verification algorithm to use for both publishers and subscribers, e.g. HS256 (default) or RS512.', 123 | ) 124 | ->addOption( 125 | 'publisher-jwt-key', 126 | null, 127 | InputOption::VALUE_OPTIONAL, 128 | 'Must contain the secret key to valid publishers\' JWT, can be omitted if jwt_key is set.', 129 | ) 130 | ->addOption( 131 | 'publisher-jwt-algorithm', 132 | null, 133 | InputOption::VALUE_OPTIONAL, 134 | 'The JWT verification algorithm to use for publishers, e.g. HS256 (default) or RS512.', 135 | ) 136 | ->addOption( 137 | 'subscriber-jwt-key', 138 | null, 139 | InputOption::VALUE_OPTIONAL, 140 | 'Must contain the secret key to valid subscribers\' JWT, can be omitted if jwt_key is set.', 141 | ) 142 | ->addOption( 143 | 'subscriber-jwt-algorithm', 144 | null, 145 | InputOption::VALUE_OPTIONAL, 146 | 'The JWT verification algorithm to use for subscribers, e.g. HS256 (default) or RS512.', 147 | ) 148 | ->addOption( 149 | 'allow-anonymous', 150 | null, 151 | InputOption::VALUE_NONE, 152 | 'Allows subscribers with no valid JWT to connect.', 153 | ); 154 | } 155 | 156 | private function displayConfiguration(array $config, SymfonyStyle $output): void 157 | { 158 | if (!$output->isVeryVerbose()) { 159 | return; 160 | } 161 | 162 | $rows = []; 163 | foreach ($config as $key => $value) { 164 | if (null === $value) { 165 | $value = 'null'; 166 | } 167 | if (\is_bool($value)) { 168 | $value = $value ? 'true' : 'false'; 169 | } 170 | $rows[] = [$key, $value]; 171 | } 172 | 173 | $output->table(['Key', 'Value'], $rows); 174 | } 175 | 176 | private function getInputOptions(InputInterface $input): array 177 | { 178 | return \array_filter( 179 | $input->getOptions(), 180 | fn($value) => null !== nullify($value) && false !== $value 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Command/StressSubscribersCommand.php: -------------------------------------------------------------------------------- 1 | getOption('jwt-key')) { 30 | $output->error('No jwt-key provided.'); 31 | 32 | return 1; 33 | } 34 | 35 | if (null === $input->getOption('topics')) { 36 | $output->error('No topics provided.'); 37 | 38 | return 1; 39 | } 40 | 41 | $jwt = (new Builder()) 42 | ->withClaim('mercure', ['subscribe' => ['*']]) 43 | ->getToken(new Sha256(), new Key($input->getOption('jwt-key'))); 44 | 45 | $client = HttpClient::create( 46 | [ 47 | 'http_version' => '2.0', 48 | 'verify_peer' => false, 49 | 'verify_host' => false, 50 | 'headers' => [ 51 | 'Authorization' => 'Bearer ' . $jwt, 52 | ], 53 | ] 54 | ); 55 | 56 | $url = new Uri($input->getArgument('url')); 57 | $qs = ''; 58 | $topics = \explode(',', $input->getOption('topics')); 59 | foreach ($topics as $topic) { 60 | $qs .= '&topic=' . $topic; 61 | } 62 | 63 | $url = $url->withQuery($qs); 64 | $nbSubscribers = (int) $input->getOption('subscribers'); 65 | 66 | $requests = function (HttpClientInterface $client, string $url) use ($nbSubscribers) { 67 | for ($i = 0; $i < $nbSubscribers; $i++) { 68 | yield $client->request('GET', $url); 69 | } 70 | }; 71 | 72 | $duration = (int) $input->getOption('duration'); 73 | $times = []; 74 | try { 75 | foreach ($client->stream($requests($client, $url), $duration) as $response => $chunk) { 76 | $now = \time(); 77 | $times[$now] ??= 0; 78 | $times[$now]++; 79 | 80 | if ($now >= ($start + $duration)) { 81 | $response->cancel(); 82 | } 83 | } 84 | } catch (TimeoutException | TransportException $e) { 85 | } 86 | 87 | $average = (int) \round(\array_sum($times) / \count($times)); 88 | $output->success(\sprintf('Average messages /sec: %d', $average)); 89 | 90 | return 0; 91 | } 92 | 93 | protected function configure(): void 94 | { 95 | $this->setDescription('(Testing purposes) Simulates n subscribers on a Mercure Hub.'); 96 | $this->addArgument('url', InputArgument::REQUIRED, 'Mercure Hub url.'); 97 | $this->addOption('jwt-key', null, InputOption::VALUE_REQUIRED, 'Subscriber JWT KEY.'); 98 | $this->addOption('topics', null, InputOption::VALUE_REQUIRED, 'Topics to subscribe, comma-separated.'); 99 | $this->addOption('duration', null, InputOption::VALUE_OPTIONAL, 'Duration of the test, in seconds.', 10); 100 | $this->addOption('subscribers', null, InputOption::VALUE_OPTIONAL, 'Number of subscribers', 100); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Configuration/Configuration.php: -------------------------------------------------------------------------------- 1 | self::DEFAULT_ADDR, 33 | self::TRANSPORT_URL => self::DEFAULT_TRANSPORT_URL, 34 | self::STORAGE_URL => null, 35 | self::METRICS_URL => null, 36 | self::CORS_ALLOWED_ORIGINS => self::DEFAULT_CORS_ALLOWED_ORIGINS, 37 | self::PUBLISH_ALLOWED_ORIGINS => self::DEFAULT_PUBLISH_ALLOWED_ORIGINS, 38 | self::JWT_KEY => null, 39 | self::JWT_ALGORITHM => self::DEFAULT_JWT_ALGORITHM, 40 | self::PUBLISHER_JWT_KEY => null, 41 | self::PUBLISHER_JWT_ALGORITHM => null, 42 | self::SUBSCRIBER_JWT_KEY => null, 43 | self::SUBSCRIBER_JWT_ALGORITHM => null, 44 | self::ALLOW_ANONYMOUS => false, 45 | ]; 46 | 47 | private array $config = self::DEFAULT_CONFIG; 48 | 49 | public function __construct(array $config = []) 50 | { 51 | foreach ($config as $key => $value) { 52 | $this->set($key, $value); 53 | } 54 | } 55 | 56 | private function export(): array 57 | { 58 | $config = \array_map(fn($value) => \is_string($value) && '' === \trim($value) ? null : $value, $this->config); 59 | if (null === $config[self::JWT_KEY] && null === $config[self::PUBLISHER_JWT_KEY]) { 60 | throw new \InvalidArgumentException( 61 | "One of \"jwt_key\" or \"publisher_jwt_key\" configuration parameter must be defined." 62 | ); 63 | } 64 | 65 | return $config; 66 | } 67 | 68 | public function asArray(): array 69 | { 70 | return $this->export(); 71 | } 72 | 73 | private function set(string $key, $value): void 74 | { 75 | $key = self::normalize($key); 76 | if (!\array_key_exists($key, self::DEFAULT_CONFIG)) { 77 | return; 78 | } 79 | if (null === $value && \is_bool(self::DEFAULT_CONFIG[$key])) { 80 | $value = self::DEFAULT_CONFIG[$key]; 81 | } 82 | $this->config[$key] = $value; 83 | } 84 | 85 | public function overrideWith(array $values): self 86 | { 87 | $clone = clone $this; 88 | foreach ($values as $key => $value) { 89 | $clone->set($key, $value); 90 | } 91 | 92 | return $clone; 93 | } 94 | 95 | private static function normalize(string $key): string 96 | { 97 | return \strtolower(\strtr($key, ['-' => '_'])); 98 | } 99 | 100 | public static function bootstrapFromCLI(InputInterface $input): self 101 | { 102 | return (new self()) 103 | ->overrideWith($_SERVER) 104 | ->overrideWith(self::filterCLIInput($input)); 105 | } 106 | 107 | private static function filterCLIInput(InputInterface $input): array 108 | { 109 | return \array_filter( 110 | $input->getOptions(), 111 | fn($value) => null !== nullify($value) && false !== $value 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Configuration/WithConfigTrait.php: -------------------------------------------------------------------------------- 1 | config = $config; 13 | 14 | return $clone; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Controller/AbstractController.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 27 | 28 | return $clone; 29 | } 30 | 31 | final public function withStorage(StorageInterface $storage): self 32 | { 33 | $clone = clone $this; 34 | $clone->storage = $storage; 35 | 36 | return $clone; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Controller/HealthController.php: -------------------------------------------------------------------------------- 1 | 'text/plain', 18 | 'Cache-Control' => 'no-cache', 19 | ]; 20 | 21 | return resolve(new Response(200, $headers)); 22 | } 23 | 24 | public function matchRequest(RequestInterface $request): bool 25 | { 26 | return \in_array($request->getMethod(), ['GET', 'HEAD'], true) 27 | && '/.well-known/mercure/health' === $request->getUri()->getPath(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Controller/PublishController.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 27 | } 28 | 29 | public function __invoke(ServerRequestInterface $request): PromiseInterface 30 | { 31 | $request = $this->withAttributes($request); 32 | $token = $request->getAttribute('token'); 33 | $topicSelectors = $this->getAuthorizedTopicSelectors($token); 34 | $input = (array) $request->getParsedBody(); 35 | $input = $this->normalizeInput($input); 36 | $canDispatchPrivateUpdates = ([] !== $topicSelectors); 37 | 38 | if ($input['private'] && !$canDispatchPrivateUpdates) { 39 | throw new AccessDeniedHttpException('You are not allowed to dispatch private updates.'); 40 | } 41 | 42 | if (false === TopicMatcher::canUpdateTopic($input['topic'], $token, $input['private'])) { 43 | throw new AccessDeniedHttpException('You are not allowed to update this topic.'); 44 | } 45 | 46 | $id = $input['id'] ?? (string) Uuid::uuid4(); 47 | $message = new Message( 48 | $id, 49 | $input['data'], 50 | (bool) $input['private'], 51 | $input['type'], 52 | null !== $input['retry'] ? (int) $input['retry'] : null 53 | ); 54 | 55 | $this->transport 56 | ->publish($input['topic'], $message) 57 | ->then(fn () => $this->storage->storeMessage($input['topic'], $message)); 58 | 59 | $this->logger->debug( 60 | \sprintf( 61 | 'Created message %s on topic %s', 62 | $message->getId(), 63 | $input['topic'], 64 | ) 65 | ); 66 | 67 | $headers = [ 68 | 'Content-Type' => 'text/plain', 69 | 'Cache-Control' => 'no-cache', 70 | ]; 71 | 72 | return resolve(new Response(201, $headers, $id)); 73 | } 74 | 75 | public function matchRequest(RequestInterface $request): bool 76 | { 77 | return 'POST' === $request->getMethod() 78 | && '/.well-known/mercure' === $request->getUri()->getPath(); 79 | } 80 | 81 | public function withConfig(array $config): self 82 | { 83 | /** @var self $clone */ 84 | $clone = parent::withConfig($config); 85 | 86 | return $clone->withAuthenticator(Authenticator::createPublisherAuthenticator($config)); 87 | } 88 | 89 | private function normalizeInput(array $input): array 90 | { 91 | if (!\is_scalar($input['topic'] ?? null)) { 92 | throw new BadRequestHttpException('Invalid topic parameter.'); 93 | } 94 | 95 | if (!\is_scalar($input['data'] ?? '')) { 96 | throw new BadRequestHttpException('Invalid data parameter.'); 97 | } 98 | 99 | if (isset($input['id']) && !Uuid::isValid($input['id'])) { 100 | throw new BadRequestHttpException('Invalid UUID.'); 101 | } 102 | 103 | $input['data'] ??= null; 104 | $input['private'] ??= false; 105 | $input['type'] ??= null; 106 | $input['retry'] ??= null; 107 | 108 | return $input; 109 | } 110 | 111 | private function withAttributes(ServerRequestInterface $request): ServerRequestInterface 112 | { 113 | try { 114 | $token = $this->authenticator->authenticate($request); 115 | } catch (\RuntimeException $e) { 116 | throw new AccessDeniedHttpException($e->getMessage()); 117 | } 118 | 119 | return $request->withAttribute('token', $token ?? null); 120 | } 121 | 122 | private function getAuthorizedTopicSelectors(?Token $token): array 123 | { 124 | if (null === $token) { 125 | throw new AccessDeniedHttpException('Invalid auth token.'); 126 | } 127 | 128 | try { 129 | $claim = $token->getClaim('mercure'); 130 | } catch (\OutOfBoundsException $e) { 131 | throw new AccessDeniedHttpException('Provided auth token doesn\'t contain the "mercure" claim.'); 132 | } 133 | 134 | $topicSelectors = $claim->publish ?? null; 135 | 136 | if (null === $topicSelectors || !\is_array($topicSelectors)) { 137 | throw new AccessDeniedHttpException('Your are not authorized to publish on this hub.'); 138 | } 139 | 140 | return $topicSelectors; 141 | } 142 | 143 | private function withAuthenticator(Authenticator $authenticator): self 144 | { 145 | $clone = clone $this; 146 | $clone->authenticator = $authenticator; 147 | 148 | return $clone; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Controller/SubscribeController.php: -------------------------------------------------------------------------------- 1 | queryStringParser = new QueryStringParser(); 37 | $this->loop = $loop; 38 | $this->logger = $logger; 39 | } 40 | 41 | public function __invoke(Request $request): PromiseInterface 42 | { 43 | 44 | if ('OPTIONS' === $request->getMethod()) { 45 | return resolve(new Response(200)); 46 | } 47 | 48 | $request = $this->withAttributes($request); 49 | 50 | $stream = new ThroughStream(); 51 | 52 | $lastEventID = $request->getAttribute('lastEventId'); 53 | $subscribedTopics = $request->getAttribute('subscribedTopics'); 54 | $this->loop 55 | ->futureTick( 56 | fn() => $this->fetchMissedMessages($lastEventID, $subscribedTopics) 57 | ->then(fn(iterable $messages) => $this->sendMissedMessages($messages, $request, $stream)) 58 | ->then(fn() => $this->subscribe($request, $stream)) 59 | ); 60 | 61 | $headers = [ 62 | 'Content-Type' => 'text/event-stream', 63 | 'Cache-Control' => 'no-cache', 64 | ]; 65 | 66 | return resolve(new Response(200, $headers, $stream)); 67 | } 68 | 69 | public function matchRequest(RequestInterface $request): bool 70 | { 71 | return \in_array($request->getMethod(), ['GET', 'OPTIONS'], true) 72 | && '/.well-known/mercure' === $request->getUri()->getPath(); 73 | } 74 | 75 | public function withConfig(array $config): self 76 | { 77 | /** @var self $clone */ 78 | $clone = parent::withConfig($config); 79 | 80 | return $clone->withAuthenticator(Authenticator::createSubscriberAuthenticator($config)); 81 | } 82 | 83 | private function withAttributes(Request $request): Request 84 | { 85 | try { 86 | $token = $this->authenticator->authenticate($request); 87 | } catch (\RuntimeException $e) { 88 | throw new AccessDeniedHttpException($e->getMessage()); 89 | } 90 | 91 | $allowAnonymous = $this->config[Configuration::ALLOW_ANONYMOUS]; 92 | if (null === $token && false === $allowAnonymous) { 93 | throw new AccessDeniedHttpException('Anonymous subscriptions are not allowed on this hub.', 401); 94 | } 95 | 96 | $qs = query_string($request->getUri(), $this->queryStringParser); 97 | $subscribedTopics = \array_map('\\urldecode', $qs->getParam('topic') ?? []); 98 | 99 | if ([] === $subscribedTopics) { 100 | throw new BadRequestHttpException('Missing "topic" parameter.'); 101 | } 102 | 103 | $request = $request 104 | ->withQueryParams($qs->getParams()) 105 | ->withAttribute('token', $token) 106 | ->withAttribute('subscribedTopics', $subscribedTopics) 107 | ->withAttribute('lastEventId', $this->getLastEventID($request, $qs->getParams())) 108 | ; 109 | 110 | return $request; 111 | } 112 | 113 | private function subscribe(Request $request, Stream $stream): PromiseInterface 114 | { 115 | $allowAnonymous = $this->config[Configuration::ALLOW_ANONYMOUS]; 116 | $subscribedTopics = $request->getAttribute('subscribedTopics'); 117 | $token = $request->getAttribute('token'); 118 | $promises = []; 119 | foreach ($subscribedTopics as $topicSelector) { 120 | if (!TopicMatcher::canSubscribeToTopic($topicSelector, $token, $allowAnonymous)) { 121 | $clientId = $request->getAttribute('clientId'); 122 | $this->logger->debug("Client {$clientId} cannot subscribe to {$topicSelector}"); 123 | continue; 124 | } 125 | $promises[] = $this->transport 126 | ->subscribe( 127 | $topicSelector, 128 | fn(string $topic, Message $message) => $this->sendIfAllowed($topic, $message, $request, $stream) 129 | ) 130 | ->then(function (string $topic) use ($request) { 131 | $clientId = $request->getAttribute('clientId'); 132 | $this->logger->debug("Client {$clientId} subscribed to {$topic}"); 133 | }); 134 | } 135 | 136 | if ([] === $promises) { 137 | return resolve(true); 138 | } 139 | 140 | return all($promises); 141 | } 142 | 143 | private function fetchMissedMessages(?string $lastEventID, array $subscribedTopics): PromiseInterface 144 | { 145 | if (null === $lastEventID) { 146 | return resolve([]); 147 | } 148 | 149 | return $this->storage->retrieveMessagesAfterId($lastEventID, $subscribedTopics); 150 | } 151 | 152 | private function sendMissedMessages(iterable $messages, Request $request, Stream $stream): PromiseInterface 153 | { 154 | $promises = []; 155 | foreach ($messages as $topic => $message) { 156 | $promises[] = $this->sendIfAllowed($topic, $message, $request, $stream); 157 | } 158 | 159 | if ([] === $promises) { 160 | return resolve(true); 161 | } 162 | 163 | return all($promises); 164 | } 165 | 166 | private function sendIfAllowed(string $topic, Message $message, Request $request, Stream $stream): PromiseInterface 167 | { 168 | $allowAnonymous = $this->config[Configuration::ALLOW_ANONYMOUS]; 169 | $subscribedTopics = $request->getAttribute('subscribedTopics'); 170 | $token = $request->getAttribute('token'); 171 | if (!TopicMatcher::canReceiveUpdate($topic, $message, $subscribedTopics, $token, $allowAnonymous)) { 172 | return resolve(false); 173 | } 174 | 175 | return resolve($this->send($topic, $message, $request, $stream)); 176 | } 177 | 178 | private function send(string $topic, Message $message, Request $request, Stream $stream): PromiseInterface 179 | { 180 | $stream->write((string) $message); 181 | $clientId = $request->getAttribute('clientId'); 182 | $id = $message->getId(); 183 | $this->logger->debug("Dispatched message {$id} to client {$clientId} on topic {$topic}"); 184 | 185 | return resolve(true); 186 | } 187 | 188 | private function getLastEventID(Request $request, array $queryParams): ?string 189 | { 190 | return nullify($request->getHeaderLine('Last-Event-ID')) 191 | ?? nullify($queryParams['Last-Event-ID'] ?? null) 192 | ?? nullify($queryParams['Last-Event-Id'] ?? null) 193 | ?? nullify($queryParams['last-event-id'] ?? null); 194 | } 195 | 196 | private function withAuthenticator(Authenticator $authenticator): self 197 | { 198 | $clone = clone $this; 199 | $clone->authenticator = $authenticator; 200 | 201 | return $clone; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Exception/Http/AccessDeniedHttpException.php: -------------------------------------------------------------------------------- 1 | statusCode = 403; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/Http/BadRequestHttpException.php: -------------------------------------------------------------------------------- 1 | statusCode = 400; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/Http/HttpException.php: -------------------------------------------------------------------------------- 1 | statusCode; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/Http/NotFoundHttpException.php: -------------------------------------------------------------------------------- 1 | statusCode = 404; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Helpers/QueryStringParser.php: -------------------------------------------------------------------------------- 1 | $value) { 20 | if (isset($params[$key]) || \in_array($key, self::FORCE_PARAM_AS_ARRAY, true)) { 21 | $params[$key] = (array) ($params[$key] ?? null); 22 | $params[$key][] = $value; 23 | } else { 24 | $params[$key] = $value; 25 | } 26 | } 27 | 28 | return $params; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Helpers/RedisHelper.php: -------------------------------------------------------------------------------- 1 | get('foo')->then( 15 | null, 16 | function (\Exception $e) use ($loop, $logger) { 17 | $logger->error(\sprintf('Redis error: %s', $e->getMessage())); 18 | $loop->stop(); 19 | } 20 | ); 21 | } 22 | 23 | public static function isRedisDSN(string $dsn): bool 24 | { 25 | return 0 === strpos($dsn, 'redis://') 26 | || 0 === strpos($dsn, 'rediss://'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Hub/Hub.php: -------------------------------------------------------------------------------- 1 | config = $config; 35 | $this->loop = $loop; 36 | $this->requestHandler = $requestHandler; 37 | $this->metricsHandler = $metricsHandler; 38 | $this->logger = $logger; 39 | $this->cors = new CORS($config); 40 | } 41 | 42 | public function run(): void 43 | { 44 | $localAddress = $this->config[Configuration::ADDR]; 45 | $this->shutdownSignal = null; 46 | $this->metricsHandler->resetUsers($localAddress); 47 | $this->loop->addSignal(SIGINT, function ($signal) { 48 | $this->stop($signal, $this->loop); 49 | }); 50 | $this->loop->addPeriodicTimer( 51 | 15, 52 | fn() => $this->metricsHandler->getNbUsers()->then( 53 | function (int $nbUsers) { 54 | $memory = \memory_get_usage(true) / 1024 / 1024; 55 | $this->logger->debug("Users: {$nbUsers} - Memory: {$memory}MB"); 56 | } 57 | ) 58 | ); 59 | 60 | $socket = $this->createSocketConnection($localAddress, $this->loop); 61 | $this->serve($localAddress, $socket, $this->loop); 62 | } 63 | 64 | public function getShutdownSignal(): ?int 65 | { 66 | return $this->shutdownSignal; 67 | } 68 | 69 | public function __invoke(ServerRequestInterface $request): PromiseInterface 70 | { 71 | return $this->requestHandler->handle($request) 72 | ->then(fn(ResponseInterface $response) => $this->cors->decorateResponse($request, $response)); 73 | } 74 | 75 | private function createSocketConnection(string $localAddress, LoopInterface $loop): Socket\Server 76 | { 77 | $socket = new Socket\Server($localAddress, $loop); 78 | $socket->on('connection', function (ConnectionInterface $connection) use ($localAddress) { 79 | $this->metricsHandler->incrementUsers($localAddress); 80 | $connection->on('close', fn() => $this->metricsHandler->decrementUsers($localAddress)); 81 | }); 82 | 83 | return $socket; 84 | } 85 | 86 | private function serve(string $localAddress, Socket\Server $socket, LoopInterface $loop): void 87 | { 88 | $server = new Http\Server($loop, $this); 89 | $server->listen($socket); 90 | 91 | $this->logger->info("Server running at http://" . $localAddress); 92 | $loop->run(); 93 | } 94 | 95 | private function stop(int $signal, LoopInterface $loop): void 96 | { 97 | $this->shutdownSignal = $signal; 98 | $loop->futureTick(function () use ($loop) { 99 | $loop->stop(); 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Hub/HubFactory.php: -------------------------------------------------------------------------------- 1 | config = $config->asArray(); 44 | $this->loop = $loop; 45 | $this->logger = $logger; 46 | $this->transportFactory = $transportFactory; 47 | $this->storageFactory = $storageFactory; 48 | $this->metricsHandlerFactory = $metricsHandlerFactory; 49 | $this->controllers = $controllers; 50 | } 51 | 52 | public function create(): HubInterface 53 | { 54 | $transport = $this->createTransport(); 55 | $storage = $this->createStorage(); 56 | $metricsHandler = $this->createMetricsHandler(); 57 | 58 | $controllers = \array_map( 59 | fn(AbstractController $controller) => $controller 60 | ->withTransport($transport) 61 | ->withStorage($storage) 62 | ->withConfig($this->config), 63 | \iterable_to_array($this->controllers) 64 | ); 65 | 66 | $requestHandler = new RequestHandler($controllers); 67 | 68 | return new Hub($this->config, $this->loop, $requestHandler, $metricsHandler, $this->logger); 69 | } 70 | 71 | private function createTransport(): TransportInterface 72 | { 73 | $factory = $this->transportFactory; 74 | $dsn = $this->config[Configuration::TRANSPORT_URL]; 75 | 76 | if (!$factory->supports($dsn)) { 77 | throw new \RuntimeException(\sprintf('Invalid transport DSN %s', $dsn)); 78 | } 79 | 80 | return await($factory->create($dsn), $this->loop); 81 | } 82 | 83 | private function createStorage(): StorageInterface 84 | { 85 | $factory = $this->storageFactory; 86 | $dsn = $this->config[Configuration::STORAGE_URL] 87 | ?? $this->config[Configuration::TRANSPORT_URL]; 88 | 89 | if (!$factory->supports($dsn)) { 90 | throw new \RuntimeException(\sprintf('Invalid storage DSN %s', $dsn)); 91 | } 92 | 93 | return await($factory->create($dsn), $this->loop); 94 | } 95 | 96 | private function createMetricsHandler(): MetricsHandlerInterface 97 | { 98 | $factory = $this->metricsHandlerFactory; 99 | $dsn = $this->config[Configuration::METRICS_URL] 100 | ?? $this->config[Configuration::STORAGE_URL] 101 | ?? $this->config[Configuration::TRANSPORT_URL]; 102 | 103 | if (!$factory->supports($dsn)) { 104 | throw new \RuntimeException(\sprintf('Invalid metrics handler DSN %s', $dsn)); 105 | } 106 | 107 | return await($factory->create($dsn), $this->loop); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Hub/HubFactoryInterface.php: -------------------------------------------------------------------------------- 1 | controllers = (fn(AbstractController ...$controllers) => $controllers)(...$controllers); 24 | } 25 | 26 | public function handle(ServerRequestInterface $request): PromiseInterface 27 | { 28 | try { 29 | $request = $this->withClientId($request); 30 | $handle = $this->getController($request); 31 | 32 | return $handle($request); 33 | } catch (HttpException $e) { 34 | return resolve( 35 | new Response( 36 | $e->getStatusCode(), 37 | ['Content-Type' => 'text/plain'], 38 | $e->getMessage(), 39 | ) 40 | ); 41 | } 42 | } 43 | 44 | private function getController(ServerRequestInterface $request): AbstractController 45 | { 46 | foreach ($this->controllers as $controller) { 47 | if (!$controller->matchRequest($request)) { 48 | continue; 49 | } 50 | 51 | return $controller; 52 | } 53 | 54 | throw new NotFoundHttpException('Not found.'); 55 | } 56 | 57 | private function withClientId(ServerRequestInterface $request): ServerRequestInterface 58 | { 59 | if (null === $request->getAttribute('clientId')) { 60 | $serverParams = $request->getServerParams(); 61 | $clientId = Uuid::uuid5( 62 | self::CLIENT_NAMESPACE, 63 | ($serverParams['REMOTE_ADDR'] ?? '') . ':' . ($serverParams['REMOTE_PORT'] ?? '') 64 | ); 65 | $request = $request->withAttribute('clientId', $clientId); 66 | } 67 | 68 | return $request; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | import('../config/{packages}/*.yaml'); 17 | $container->import('../config/{packages}/' . $this->environment . '/*.yaml'); 18 | 19 | if (is_file(\dirname(__DIR__) . '/config/services.yaml')) { 20 | $container->import('../config/{services}.yaml'); 21 | $container->import('../config/{services}_' . $this->environment . '.yaml'); 22 | } elseif (is_file($path = \dirname(__DIR__) . '/config/services.php')) { 23 | (require $path)($container->withPath($path), $this); 24 | } 25 | } 26 | 27 | protected function configureRoutes(RoutingConfigurator $routes): void 28 | { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Metrics/MetricsHandlerFactory.php: -------------------------------------------------------------------------------- 1 | factories = $factories; 17 | } 18 | 19 | public function supports(string $dsn): bool 20 | { 21 | foreach ($this->getFactories() as $factory) { 22 | if ($factory->supports($dsn)) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | 30 | public function create(string $dsn): PromiseInterface 31 | { 32 | foreach ($this->getFactories() as $factory) { 33 | if (!$factory->supports($dsn)) { 34 | continue; 35 | } 36 | 37 | return $factory->create($dsn); 38 | } 39 | 40 | throw new \RuntimeException(\sprintf('Invalid metrics handler DSN %s', $dsn)); 41 | } 42 | 43 | private function getFactories(): array 44 | { 45 | if (\is_array($this->factories)) { 46 | return $this->factories; 47 | } 48 | 49 | $factories = []; 50 | foreach ($this->factories as $factory) { 51 | if ($factory === $this) { 52 | continue; 53 | } 54 | $factories[] = $factory; 55 | } 56 | return $this->factories = (fn(MetricsHandlerFactoryInterface ...$factories) => $factories)(...$factories); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Metrics/MetricsHandlerFactoryInterface.php: -------------------------------------------------------------------------------- 1 | nbUsers = 0; 17 | 18 | return resolve(); 19 | } 20 | 21 | public function incrementUsers(string $localAddress): PromiseInterface 22 | { 23 | $this->nbUsers++; 24 | 25 | return resolve(); 26 | } 27 | 28 | public function decrementUsers(string $localAddress): PromiseInterface 29 | { 30 | $this->nbUsers--; 31 | 32 | return resolve(); 33 | } 34 | 35 | public function getNbUsers(): PromiseInterface 36 | { 37 | return resolve($this->nbUsers); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Metrics/PHP/PHPMetricsHandlerFactory.php: -------------------------------------------------------------------------------- 1 | client = $client; 21 | } 22 | 23 | public function resetUsers(string $localAddress): PromiseInterface 24 | { 25 | /** @phpstan-ignore-next-line */ 26 | return $this->client->set('users:' . $localAddress, 0); 27 | } 28 | 29 | public function incrementUsers(string $localAddress): PromiseInterface 30 | { 31 | /** @phpstan-ignore-next-line */ 32 | return $this->client->incr('users:' . $localAddress); 33 | } 34 | 35 | public function decrementUsers(string $localAddress): PromiseInterface 36 | { 37 | /** @phpstan-ignore-next-line */ 38 | return $this->client->decr('users:' . $localAddress); 39 | } 40 | 41 | public function getNbUsers(): PromiseInterface 42 | { 43 | /** @phpstan-ignore-next-line */ 44 | return $this->client->keys('users:*') 45 | ->then( 46 | function (array $keys) { 47 | $promises = []; 48 | foreach ($keys as $key) { 49 | $promises[] = $this->client->get($key); /** @phpstan-ignore-line */ 50 | } 51 | 52 | return all($promises)->then(fn (array $results): int => \array_sum($results)); 53 | } 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Metrics/Redis/RedisMetricsHandlerFactory.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 19 | } 20 | 21 | public function supports(string $dsn): bool 22 | { 23 | return RedisHelper::isRedisDSN($dsn); 24 | } 25 | 26 | public function create(string $dsn): PromiseInterface 27 | { 28 | $factory = new Factory($this->loop); 29 | 30 | return $factory->createClient($dsn) 31 | ->then(fn (AsynchronousClient $client) => new RedisMetricsHandler($client)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Model/Message.php: -------------------------------------------------------------------------------- 1 | id = $id; 21 | $this->data = $data; 22 | $this->private = $private; 23 | $this->event = $event; 24 | $this->retry = $retry; 25 | } 26 | 27 | public function getId(): string 28 | { 29 | return $this->id; 30 | } 31 | 32 | public function getData(): ?string 33 | { 34 | return $this->data; 35 | } 36 | 37 | public function isPrivate(): bool 38 | { 39 | return $this->private; 40 | } 41 | 42 | public function __toString(): string 43 | { 44 | $output = 'id:' . $this->id . \PHP_EOL; 45 | 46 | if (null !== $this->event) { 47 | $output .= 'event:' . $this->event . \PHP_EOL; 48 | } 49 | 50 | if (null !== $this->retry) { 51 | $output .= 'retry:' . $this->retry . \PHP_EOL; 52 | } 53 | 54 | if (null !== $this->data) { 55 | // If $data contains line breaks, we have to serialize it in a different way 56 | if (false !== \strpos($this->data, \PHP_EOL)) { 57 | $lines = \explode(\PHP_EOL, $this->data); 58 | foreach ($lines as $line) { 59 | $output .= 'data:' . $line . \PHP_EOL; 60 | } 61 | } else { 62 | $output .= 'data:' . $this->data . \PHP_EOL; 63 | } 64 | } 65 | 66 | return $output . \PHP_EOL; 67 | } 68 | 69 | public function jsonSerialize(): array 70 | { 71 | return \array_filter( 72 | [ 73 | 'id' => $this->id, 74 | 'data' => $this->data, 75 | 'private' => $this->private, 76 | 'event' => $this->event, 77 | 'retry' => $this->retry, 78 | ], 79 | fn ($value) => null !== $value 80 | ); 81 | } 82 | 83 | public static function fromArray(array $event): self 84 | { 85 | return new self( 86 | $event['id'], 87 | $event['data'] ?? null, 88 | $event['private'] ?? false, 89 | $event['type'] ?? null, 90 | $event['retry'] ?? null, 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Security/Authenticator.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 24 | $this->key = $key; 25 | $this->signer = $signer; 26 | } 27 | 28 | public function authenticate(ServerRequestInterface $request): ?Token 29 | { 30 | $token = self::extractToken($request, $this->parser, $this->key, $this->signer); 31 | 32 | if (null === $token) { 33 | return null; 34 | } 35 | 36 | if (!$token->verify($this->signer, $this->key)) { 37 | throw new RuntimeException('Invalid token signature.'); 38 | } 39 | 40 | if ($token->isExpired()) { 41 | throw new RuntimeException('Your token has expired.'); 42 | } 43 | 44 | return $token; 45 | } 46 | 47 | private static function extractRawToken(ServerRequestInterface $request): ?string 48 | { 49 | if ($request->hasHeader('Authorization')) { 50 | $payload = \trim($request->getHeaderLine('Authorization')); 51 | if (0 === \strpos($payload, 'Bearer ')) { 52 | return \substr($payload, 7); 53 | } 54 | } 55 | 56 | $cookies = $request->getCookieParams(); 57 | return $cookies['mercureAuthorization'] ?? null; 58 | } 59 | 60 | private static function extractToken(ServerRequestInterface $request, Parser $parser, Key $key, Signer $signer): ?Token 61 | { 62 | $payload = self::extractRawToken($request); 63 | if (null === $payload) { 64 | return null; 65 | } 66 | 67 | try { 68 | return $parser->parse($payload); 69 | } catch (RuntimeException $e) { 70 | throw new RuntimeException("Cannot decode token."); 71 | } 72 | } 73 | 74 | public static function createPublisherAuthenticator(array $config): Authenticator 75 | { 76 | $publisherKey = $config[Configuration::PUBLISHER_JWT_KEY] ?? $config[Configuration::JWT_KEY]; 77 | $publisherAlgorithm = $config[Configuration::PUBLISHER_JWT_ALGORITHM] ?? $config[Configuration::JWT_ALGORITHM]; 78 | 79 | return new self( 80 | new Parser(), 81 | new Key($publisherKey), 82 | get_signer($publisherAlgorithm) 83 | ); 84 | } 85 | 86 | public static function createSubscriberAuthenticator(array $config): Authenticator 87 | { 88 | $subscriberKey = $config[Configuration::SUBSCRIBER_JWT_KEY] ?? $config[Configuration::JWT_KEY]; 89 | $subscriberAlgorithm = $config[Configuration::SUBSCRIBER_JWT_ALGORITHM] ?? $config[Configuration::JWT_ALGORITHM]; 90 | 91 | return new self( 92 | new Parser(), 93 | new Key($subscriberKey), 94 | get_signer($subscriberAlgorithm) 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Security/CORS.php: -------------------------------------------------------------------------------- 1 | subscriberConfig = ['origins' => self::normalizeAllowedOrigins($config[Configuration::CORS_ALLOWED_ORIGINS])]; 19 | $this->publisherConfig = ['origins' => self::normalizeAllowedOrigins($config[Configuration::PUBLISH_ALLOWED_ORIGINS])]; 20 | $this->subscriberConfig['all'] = self::allowsAllOrigins($this->subscriberConfig['origins']); 21 | $this->publisherConfig['all'] = self::allowsAllOrigins($this->publisherConfig['origins']); 22 | } 23 | 24 | public function decorateResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 25 | { 26 | $headers = $this->getCorsHeaders($request); 27 | foreach ($headers as $key => $value) { 28 | $response = $response->withHeader($key, $value); 29 | } 30 | 31 | return $response; 32 | } 33 | 34 | private function getCorsHeaders(ServerRequestInterface $request): array 35 | { 36 | $origin = nullify($request->getHeaderLine('Origin')) ?? nullify($request->getHeaderLine('Referer')); 37 | if (!$origin) { 38 | return []; 39 | } 40 | 41 | $config = 'POST' === $request->getMethod() ? $this->publisherConfig : $this->subscriberConfig; 42 | 43 | if (!$config['all'] && !\in_array($origin, $config['origins'], true)) { 44 | return []; 45 | } 46 | 47 | return [ 48 | 'Access-Control-Allow-Origin' => $origin, 49 | 'Access-Control-Allow-Credentials' => 'true', 50 | 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS', 51 | 'Access-Control-Allow-Headers' => 'Cache-control, Authorization, Last-Event-ID', 52 | 'Access-Control-Max-Age' => 3600, 53 | ]; 54 | } 55 | 56 | private static function normalizeAllowedOrigins(string $allowedOrigins): array 57 | { 58 | $allowedOrigins = \strtr($allowedOrigins, [';' => ' ', ',' => ' ']); 59 | 60 | return \array_map('\\trim', \explode(' ', $allowedOrigins)); 61 | } 62 | 63 | private static function allowsAllOrigins(array $origins): bool 64 | { 65 | return \in_array('*', $origins, true); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Security/TopicMatcher.php: -------------------------------------------------------------------------------- 1 | extract($topicSelector, $topic, true); 37 | } 38 | 39 | public static function canSubscribeToTopic(string $topic, ?Token $token, bool $allowAnonymous): bool 40 | { 41 | if (true === $allowAnonymous) { 42 | return true; 43 | } 44 | 45 | if (null === $token) { 46 | return false; 47 | } 48 | 49 | try { 50 | $claim = (array) $token->getClaim('mercure'); 51 | } catch (\OutOfBoundsException $e) { 52 | return false; 53 | } 54 | 55 | $allowedTopics = $claim['subscribe'] ?? []; 56 | $deniedTopics = $claim['subscribe_exclude'] ?? []; 57 | 58 | return self::matchesTopicSelectors($topic, $allowedTopics) 59 | && !self::matchesTopicSelectors($topic, $deniedTopics); 60 | } 61 | 62 | public static function canUpdateTopic(string $topic, Token $token, bool $privateUpdate): bool 63 | { 64 | try { 65 | $claim = (array) $token->getClaim('mercure'); 66 | } catch (\OutOfBoundsException $e) { 67 | return false; 68 | } 69 | 70 | $allowedTopics = $claim['publish'] ?? null; 71 | $deniedTopics = $claim['publish_exclude'] ?? []; 72 | 73 | // If not defined, then the publisher MUST NOT be authorized to dispatch any update 74 | if (null === $allowedTopics || !\is_array($allowedTopics)) { 75 | return false; 76 | } 77 | 78 | if (true === $privateUpdate) { 79 | return self::matchesTopicSelectors($topic, $allowedTopics ?? []) 80 | && !self::matchesTopicSelectors($topic, $deniedTopics); 81 | } 82 | 83 | return !self::matchesTopicSelectors($topic, $deniedTopics); 84 | } 85 | 86 | public static function canReceiveUpdate( 87 | string $topic, 88 | Message $message, 89 | array $subscribedTopics, 90 | ?Token $token, 91 | bool $allowAnonymous 92 | ): bool { 93 | if (!self::matchesTopicSelectors($topic, $subscribedTopics)) { 94 | return false; 95 | } 96 | 97 | if (null === $token && false === $allowAnonymous) { 98 | return false; 99 | } 100 | 101 | if (!$message->isPrivate()) { 102 | return true; 103 | } 104 | 105 | if (null === $token) { 106 | return false; 107 | } 108 | 109 | try { 110 | $claim = (array) $token->getClaim('mercure'); 111 | } catch (\OutOfBoundsException $e) { 112 | return false; 113 | } 114 | 115 | $allowedTopics = $claim['subscribe'] ?? []; 116 | $deniedTopics = $claim['subscribe_exclude'] ?? []; 117 | 118 | return self::matchesTopicSelectors($topic, $allowedTopics) 119 | && !self::matchesTopicSelectors($topic, $deniedTopics); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Storage/NullStorage/NullStorage.php: -------------------------------------------------------------------------------- 1 | size = $size; 25 | } 26 | 27 | public function retrieveMessagesAfterId(string $id, array $subscribedTopics): PromiseInterface 28 | { 29 | if (self::EARLIEST === $id) { 30 | return resolve($this->getAllMessages($subscribedTopics)); 31 | } 32 | 33 | return resolve($this->getMessagesAfterId($id, $subscribedTopics)); 34 | } 35 | 36 | public function storeMessage(string $topic, Message $message): PromiseInterface 37 | { 38 | if (0 === $this->size) { 39 | return resolve(true); 40 | } 41 | 42 | if ($this->currentSize >= $this->size) { 43 | \array_shift($this->messages); 44 | } 45 | $this->messages[] = [$topic, $message]; 46 | $this->currentSize++; 47 | 48 | return resolve(true); 49 | } 50 | 51 | private function getMessagesAfterId(string $id, array $subscribedTopics): iterable 52 | { 53 | $ignore = true; 54 | foreach ($this->messages as [$topic, $message]) { 55 | if ($message->getId() === $id) { 56 | $ignore = false; 57 | continue; 58 | } 59 | if ($ignore || !TopicMatcher::matchesTopicSelectors($topic, $subscribedTopics)) { 60 | continue; 61 | } 62 | yield $topic => $message; 63 | } 64 | } 65 | 66 | private function getAllMessages(array $subscribedTopics): iterable 67 | { 68 | foreach ($this->messages as [$topic, $message]) { 69 | if (!TopicMatcher::matchesTopicSelectors($topic, $subscribedTopics)) { 70 | continue; 71 | } 72 | yield $topic => $message; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Storage/PHP/PHPStorageFactory.php: -------------------------------------------------------------------------------- 1 | getParam('size') ?? 0; 23 | 24 | return resolve(new PHPStorage((int) $size)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Storage/Redis/RedisStorage.php: -------------------------------------------------------------------------------- 1 | async = $asyncClient; 22 | $this->sync = $syncClient; 23 | } 24 | 25 | public function retrieveMessagesAfterId(string $id, array $subscribedTopics): PromiseInterface 26 | { 27 | return resolve($this->findNextMessages($id, $subscribedTopics)); 28 | } 29 | 30 | public function storeMessage(string $topic, Message $message): PromiseInterface 31 | { 32 | $id = $message->getId(); 33 | $payload = \json_encode($message, \JSON_THROW_ON_ERROR); 34 | 35 | /** @phpstan-ignore-next-line */ 36 | return $this->async->set('data:' . $id, $topic . \PHP_EOL . $payload) 37 | ->then(fn() => $this->getLastEventId()) 38 | ->then(fn(?string $lastEventId) => $this->storeLastEventId($lastEventId, $id)) 39 | ->then(fn() => $id); 40 | } 41 | 42 | private function getLastEventId(): PromiseInterface 43 | { 44 | return $this->async->get('Last-Event-ID'); /** @phpstan-ignore-line */ 45 | } 46 | 47 | private function storeLastEventId(?string $previousEventId, string $newEventId): PromiseInterface 48 | { 49 | $promise = $this->async->set('Last-Event-ID', $newEventId); /** @phpstan-ignore-line */ 50 | 51 | if (null === $previousEventId) { 52 | return $promise; 53 | } 54 | 55 | /** @phpstan-ignore-next-line */ 56 | return $promise->then(fn() => $this->async->set('next:' . $previousEventId, $newEventId)); 57 | } 58 | 59 | private function findNextMessages(string $id, array $subscribedTopics): iterable 60 | { 61 | $nextId = $this->sync->get('next:' . $id); 62 | 63 | if (null === $nextId) { 64 | return []; 65 | } 66 | 67 | $payload = $this->sync->get('data:' . $nextId); 68 | 69 | if (null === $payload) { 70 | return []; 71 | } 72 | 73 | $item = \explode(\PHP_EOL, $payload); 74 | $topic = \array_shift($item); 75 | $message = Message::fromArray( 76 | \json_decode( 77 | \implode(\PHP_EOL, $item), 78 | true, 79 | 512, 80 | \JSON_THROW_ON_ERROR 81 | ) 82 | ); 83 | 84 | if (TopicMatcher::matchesTopicSelectors($topic, $subscribedTopics)) { 85 | yield $topic => $message; 86 | } 87 | 88 | yield from $this->findNextMessages($message->getId(), $subscribedTopics); // Sync client needed because of this 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Storage/Redis/RedisStorageFactory.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 25 | $this->logger = $logger; 26 | } 27 | 28 | public function supports(string $dsn): bool 29 | { 30 | return RedisHelper::isRedisDSN($dsn); 31 | } 32 | 33 | public function create(string $dsn): PromiseInterface 34 | { 35 | $factory = new Factory($this->loop); 36 | $promises = [ 37 | 'async' => $factory->createClient($dsn) 38 | ->then( 39 | function (AsynchronousClient $client) { 40 | $client->on( 41 | 'close', 42 | function () { 43 | $this->logger->error('Connection closed.'); 44 | $this->loop->stop(); 45 | } 46 | ); 47 | 48 | return $client; 49 | }, 50 | function (\Exception $exception) { 51 | $this->loop->stop(); 52 | $this->logger->error($exception->getMessage()); 53 | } 54 | ), 55 | 'sync' => resolve(new SynchronousClient($dsn)), 56 | ]; 57 | 58 | return all($promises) 59 | ->then( 60 | function (iterable $results): array { 61 | $clients = []; 62 | foreach ($results as $key => $client) { 63 | $clients[$key] = $client; 64 | } 65 | 66 | // Sounds weird, but helps in detecting an anomaly during connection 67 | RedisHelper::testAsynchronousClient($clients['async'], $this->loop, $this->logger); 68 | 69 | return $clients; 70 | } 71 | ) 72 | ->then( 73 | fn (array $clients): RedisStorage => new RedisStorage( 74 | $clients['async'], 75 | $clients['sync'], 76 | ) 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Storage/StorageFactory.php: -------------------------------------------------------------------------------- 1 | factories = $factories; 17 | } 18 | 19 | public function supports(string $dsn): bool 20 | { 21 | foreach ($this->getFactories() as $factory) { 22 | if ($factory->supports($dsn)) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | 30 | public function create(string $dsn): PromiseInterface 31 | { 32 | foreach ($this->getFactories() as $factory) { 33 | if (!$factory->supports($dsn)) { 34 | continue; 35 | } 36 | 37 | return $factory->create($dsn); 38 | } 39 | 40 | throw new \RuntimeException(\sprintf('Invalid storage DSN %s', $dsn)); 41 | } 42 | 43 | private function getFactories(): array 44 | { 45 | if (\is_array($this->factories)) { 46 | return $this->factories; 47 | } 48 | 49 | $factories = []; 50 | foreach ($this->factories as $factory) { 51 | if ($factory === $this) { 52 | continue; 53 | } 54 | $factories[] = $factory; 55 | } 56 | return $this->factories = (fn(StorageFactoryInterface ...$factories) => $factories)(...$factories); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Storage/StorageFactoryInterface.php: -------------------------------------------------------------------------------- 1 | Message objects, or return an empty iterable. 17 | */ 18 | public function retrieveMessagesAfterId(string $id, array $subscribedTopics): PromiseInterface; 19 | 20 | public function storeMessage(string $topic, Message $message): PromiseInterface; 21 | } 22 | -------------------------------------------------------------------------------- /src/Transport/PHP/PHPTransport.php: -------------------------------------------------------------------------------- 1 | emitter = $emitter ?? new EventEmitter(); 21 | } 22 | 23 | public function publish(string $topic, Message $message): PromiseInterface 24 | { 25 | $this->emitter->emit('message', [$topic, $message]); 26 | return resolve($message->getId()); 27 | } 28 | 29 | public function subscribe(string $subscribedTopic, callable $callback): PromiseInterface 30 | { 31 | $this->emitter->on('message', function (string $topic, Message $message) use ($callback, $subscribedTopic) { 32 | if (!TopicMatcher::matchesTopicSelectors($topic, [$subscribedTopic])) { 33 | return; 34 | } 35 | return $callback($topic, $message); 36 | }); 37 | return resolve($subscribedTopic); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Transport/PHP/PHPTransportFactory.php: -------------------------------------------------------------------------------- 1 | emitter = $emitter ?? new EventEmitter(); 22 | } 23 | 24 | public function supports(string $dsn): bool 25 | { 26 | return 0 === \strpos($dsn, 'php://'); 27 | } 28 | 29 | public function create(string $dsn): PromiseInterface 30 | { 31 | return resolve(new PHPTransport($this->emitter)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Transport/Redis/RedisTransport.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 26 | $this->publisher = $publisher; 27 | } 28 | 29 | public function publish(string $topic, Message $message): PromiseInterface 30 | { 31 | $payload = \json_encode($message, \JSON_THROW_ON_ERROR); 32 | 33 | /** @phpstan-ignore-next-line */ 34 | return $this->publisher 35 | ->publish($topic, $payload); 36 | } 37 | 38 | public function subscribe(string $topicSelector, callable $callback): PromiseInterface 39 | { 40 | // Uri templates 41 | if (false !== \strpos($topicSelector, '{')) { 42 | return $this->subscribePattern($topicSelector, $callback); 43 | } 44 | 45 | /** @phpstan-ignore-next-line */ 46 | $this->subscriber->subscribe($topicSelector); 47 | $this->subscriber->on( 48 | 'message', 49 | function (string $topic, string $payload) use ($topicSelector, $callback) { 50 | $this->dispatch($topic, $payload, $topicSelector, $callback); 51 | } 52 | ); 53 | 54 | return resolve($topicSelector); 55 | } 56 | 57 | private function subscribePattern(string $topicSelector, callable $callback): PromiseInterface 58 | { 59 | static $uriTemplate; 60 | $uriTemplate ??= new UriTemplate(); 61 | $keys = \array_keys($uriTemplate->extract($topicSelector, $topicSelector, false)); 62 | 63 | // Replaces /author/{author}/books/{book} by /author/*/books/* to match Redis' patterns 64 | $channel = $uriTemplate->expand( 65 | $topicSelector, 66 | \array_combine( 67 | $keys, 68 | \array_fill(0, count($keys), '*') 69 | ) 70 | ); 71 | $channel = \strtr($channel, ['%2A' => '*']); 72 | 73 | /** @phpstan-ignore-next-line */ 74 | $this->subscriber->psubscribe($channel); 75 | $this->subscriber->on( 76 | 'pmessage', 77 | function (string $pattern, string $topic, string $payload) use ($topicSelector, $callback) { 78 | $this->dispatch($topic, $payload, $topicSelector, $callback); 79 | } 80 | ); 81 | 82 | return resolve($topicSelector); 83 | } 84 | 85 | private function dispatch(string $topic, string $payload, string $topicSelector, callable $callback): void 86 | { 87 | if (!TopicMatcher::matchesTopicSelectors($topic, [$topicSelector])) { 88 | return; 89 | } 90 | 91 | $message = Message::fromArray( 92 | \json_decode( 93 | $payload, 94 | true, 95 | 512, 96 | \JSON_THROW_ON_ERROR 97 | ) 98 | ); 99 | 100 | $callback( 101 | $topic, 102 | $message 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Transport/Redis/RedisTransportFactory.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 23 | $this->logger = $logger; 24 | } 25 | 26 | public function supports(string $dsn): bool 27 | { 28 | return RedisHelper::isRedisDSN($dsn); 29 | } 30 | 31 | public function create(string $dsn): PromiseInterface 32 | { 33 | $factory = new Factory($this->loop); 34 | $promises = [ 35 | 'subscriber' => $factory->createClient($dsn) 36 | ->then( 37 | function (AsynchronousClient $client) { 38 | $client->on( 39 | 'close', 40 | function () { 41 | $this->logger->error('Connection closed.'); 42 | $this->loop->stop(); 43 | } 44 | ); 45 | 46 | return $client; 47 | }, 48 | function (\Exception $exception) { 49 | $this->loop->stop(); 50 | $this->logger->error($exception->getMessage()); 51 | } 52 | ), 53 | 'publisher' => $factory->createClient($dsn) 54 | ->then( 55 | function (AsynchronousClient $client) { 56 | $client->on( 57 | 'close', 58 | function () { 59 | $this->logger->error('Connection closed.'); 60 | $this->loop->stop(); 61 | } 62 | ); 63 | 64 | return $client; 65 | }, 66 | function (\Exception $exception) { 67 | $this->loop->stop(); 68 | $this->logger->error($exception->getMessage()); 69 | } 70 | ), 71 | ]; 72 | 73 | return all($promises) 74 | ->then( 75 | function (iterable $results): array { 76 | $clients = []; 77 | foreach ($results as $key => $client) { 78 | $clients[$key] = $client; 79 | } 80 | 81 | RedisHelper::testAsynchronousClient($clients['subscriber'], $this->loop, $this->logger); 82 | RedisHelper::testAsynchronousClient($clients['publisher'], $this->loop, $this->logger); 83 | 84 | return $clients; 85 | } 86 | ) 87 | ->then( 88 | fn (array $clients) => new RedisTransport($clients['subscriber'], $clients['publisher']) 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Transport/TransportFactory.php: -------------------------------------------------------------------------------- 1 | factories = $factories; 17 | } 18 | 19 | public function supports(string $dsn): bool 20 | { 21 | foreach ($this->getFactories() as $factory) { 22 | if ($factory->supports($dsn)) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | 30 | public function create(string $dsn): PromiseInterface 31 | { 32 | foreach ($this->getFactories() as $factory) { 33 | if (!$factory->supports($dsn)) { 34 | continue; 35 | } 36 | 37 | return $factory->create($dsn); 38 | } 39 | 40 | throw new \RuntimeException(\sprintf('Invalid transport DSN %s', $dsn)); 41 | } 42 | 43 | private function getFactories(): array 44 | { 45 | if (\is_array($this->factories)) { 46 | return $this->factories; 47 | } 48 | 49 | $factories = []; 50 | foreach ($this->factories as $factory) { 51 | if ($factory === $this) { 52 | continue; 53 | } 54 | $factories[] = $factory; 55 | } 56 | return $this->factories = (fn(TransportFactoryInterface ...$factories) => $factories)(...$factories); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Transport/TransportFactoryInterface.php: -------------------------------------------------------------------------------- 1 | new Signer\Hmac\Sha256(), 24 | 'RS512' => new Signer\Rsa\Sha512(), 25 | ]; 26 | 27 | if (!isset($map[$algorithm])) { 28 | throw new \InvalidArgumentException(\sprintf('Invalid algorithm %s.', $algorithm)); 29 | } 30 | 31 | return $map[$algorithm]; 32 | } 33 | 34 | function without_nullish_values(array $array): array 35 | { 36 | return \array_filter( 37 | $array, 38 | fn($value) => null !== nullify($value) && false !== $value 39 | ); 40 | } 41 | 42 | function get_options_from_input(InputInterface $input): array 43 | { 44 | return \array_filter( 45 | $input->getOptions(), 46 | fn($value) => null !== nullify($value) && false !== $value 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "php": { 3 | "version": "7.4" 4 | }, 5 | "psr/cache": { 6 | "version": "1.0.1" 7 | }, 8 | "symfony/cache": { 9 | "version": "v5.1.5" 10 | }, 11 | "symfony/cache-contracts": { 12 | "version": "v2.2.0" 13 | }, 14 | "symfony/config": { 15 | "version": "v5.1.5" 16 | }, 17 | "symfony/dependency-injection": { 18 | "version": "v5.1.5" 19 | }, 20 | "symfony/dotenv": { 21 | "version": "v5.1.5" 22 | }, 23 | "symfony/error-handler": { 24 | "version": "v5.1.5" 25 | }, 26 | "symfony/flex": { 27 | "version": "1.0", 28 | "recipe": { 29 | "repo": "github.com/symfony/recipes", 30 | "branch": "master", 31 | "version": "1.0", 32 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 33 | }, 34 | "files": [ 35 | ".env" 36 | ] 37 | }, 38 | "symfony/framework-bundle": { 39 | "version": "5.1", 40 | "recipe": { 41 | "repo": "github.com/symfony/recipes", 42 | "branch": "master", 43 | "version": "5.1", 44 | "ref": "e1b2770f2404d8307450a49cabfc3b2ff3184792" 45 | }, 46 | "files": [ 47 | "config/packages/cache.yaml", 48 | "config/packages/framework.yaml", 49 | "config/packages/test/framework.yaml", 50 | "config/routes/dev/framework.yaml", 51 | "config/services.yaml", 52 | "public/index.php", 53 | "src/Controller/.gitignore", 54 | "src/Kernel.php" 55 | ] 56 | }, 57 | "symfony/http-foundation": { 58 | "version": "v5.1.5" 59 | }, 60 | "symfony/http-kernel": { 61 | "version": "v5.1.5" 62 | }, 63 | "symfony/routing": { 64 | "version": "5.1", 65 | "recipe": { 66 | "repo": "github.com/symfony/recipes", 67 | "branch": "master", 68 | "version": "5.1", 69 | "ref": "b4f3e7c95e38b606eef467e8a42a8408fc460c43" 70 | }, 71 | "files": [ 72 | "config/packages/prod/routing.yaml", 73 | "config/packages/routing.yaml", 74 | "config/routes.yaml" 75 | ] 76 | }, 77 | "symfony/var-exporter": { 78 | "version": "v5.1.5" 79 | }, 80 | "symfony/yaml": { 81 | "version": "v5.1.5" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Classes/FilterIterator.php: -------------------------------------------------------------------------------- 1 | items = $items; 20 | $this->filter = $filter; 21 | } 22 | 23 | private function filtered() 24 | { 25 | if (null === $this->filter) { 26 | return $this->items; 27 | } 28 | 29 | if (\is_array($this->items)) { 30 | return \array_filter($this->items, $this->filter); 31 | } 32 | 33 | $iterator = $this->items; 34 | if (!$iterator instanceof \Iterator) { 35 | $iterator = new \IteratorIterator($iterator); 36 | } 37 | 38 | return new \CallbackFilterIterator($iterator, $this->filter); 39 | } 40 | 41 | public function getIterator() 42 | { 43 | $items = null === $this->filter ? $this->items : $this->filtered(); 44 | 45 | foreach ($this->filtered() as $key => $value) { 46 | yield $key => $value; 47 | } 48 | } 49 | 50 | public function filter($filter): self 51 | { 52 | return new self($this, $filter); 53 | } 54 | 55 | public function asArray() 56 | { 57 | $filtered = $this->filtered(); 58 | 59 | return \is_array($filtered) ? $filtered : \iterator_to_array($filtered); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Classes/NullTransport.php: -------------------------------------------------------------------------------- 1 | getId()); 16 | } 17 | 18 | public function subscribe(string $topic, callable $callback): PromiseInterface 19 | { 20 | return resolve($topic); 21 | } 22 | 23 | public function retrieveMessagesAfterId(string $id): PromiseInterface 24 | { 25 | return resolve([]); 26 | } 27 | 28 | public function incrementUsers(): PromiseInterface 29 | { 30 | return resolve(); 31 | } 32 | 33 | public function decrementUsers(): PromiseInterface 34 | { 35 | return resolve(); 36 | } 37 | 38 | public function getNbUsers(): PromiseInterface 39 | { 40 | return resolve(0); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Classes/NullTransportFactory.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | private array $services; 11 | 12 | public function __construct(array $services = []) 13 | { 14 | foreach ($services as $tag => $service) { 15 | if (!\is_iterable($service)) { 16 | throw new \InvalidArgumentException(\sprintf('Provided services for `%s` are not iterable.', $tag)); 17 | } 18 | $this->services[$tag] = $service; 19 | } 20 | } 21 | 22 | public function getServicesByTag(string $tag, bool $throw = true): iterable 23 | { 24 | if (!isset($this->services[$tag]) && $throw) { 25 | throw new \InvalidArgumentException(\sprintf('Unknown tag `%s`.', $tag)); 26 | } 27 | 28 | return $this->services[$tag] ?? []; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Helpers.php: -------------------------------------------------------------------------------- 1 | boot(); 18 | 19 | return $kernel; 20 | })(); 21 | 22 | return $kernel; 23 | } 24 | 25 | /** 26 | * Shortcut to the test container (all services are public). 27 | */ 28 | function container(): ContainerInterface 29 | { 30 | $container = app()->getContainer(); 31 | 32 | return $container->get('test.service_container'); 33 | } 34 | -------------------------------------------------------------------------------- /tests/Integration/MissedEvents/MissedEventsTest.php: -------------------------------------------------------------------------------- 1 | $value) { 27 | $builder = $builder->withClaim($name, $value); 28 | } 29 | 30 | static $signer; 31 | $signer ??= new Sha256(); 32 | 33 | return $builder->getToken($signer, new Key($key)); 34 | } 35 | 36 | function publish(HttpClientInterface $client, UriInterface $publishUrl, Token $token, iterable $messages) 37 | { 38 | foreach ($messages as $topic => $id) { 39 | $body = [ 40 | 'topic' => $topic, 41 | 'data' => \sprintf("published on %s", $topic), 42 | 'id' => $id, 43 | ]; 44 | 45 | yield $client->request( 46 | 'POST', 47 | $publishUrl, 48 | [ 49 | 'headers' => ['Authorization' => \sprintf("Bearer %s", $token)], 50 | 'body' => $body, 51 | 'user_data' => $id, 52 | ] 53 | ); 54 | } 55 | } 56 | 57 | $url = new Uri(sprintf("http://%s", $_SERVER['ADDR'])); 58 | $transport = $_SERVER['TRANSPORT_URL'] ?? null; 59 | $process = null; 60 | 61 | beforeAll(function () use ($transport, &$process) { 62 | if (null === $transport) { 63 | throw new \RuntimeException('Cannot run test, missing TRANSPORT_URL env var.'); 64 | } 65 | $process = new Process(['bin/mercure'], \dirname(__DIR__, 3)); 66 | $process->setTimeout(15); 67 | $process->setIdleTimeout(15); 68 | $process->start(); 69 | \sleep(1); 70 | }); 71 | 72 | afterAll(function () use (&$process) { 73 | $process->stop(1, \SIGINT); 74 | }); 75 | 76 | it('successfully receives missed events', function () use ($url) { 77 | 78 | $loop = Factory::create(); 79 | for ($uuids = [], $i = 1; $i <= 3; $i++) { 80 | $uuids[] = (string) Uuid::uuid4(); 81 | } 82 | 83 | $token = createJWT(['mercure' => ['publish' => ['*']]], $_SERVER['JWT_KEY']); 84 | $client = HttpClient::create(); 85 | $subscribeUrl = $url->withPath('/.well-known/mercure') 86 | ->withQuery('topic=/foo&topic=/foobar/{id}&Last-Event-ID=' . $uuids[0]); 87 | $publishUrl = $url->withPath('/.well-known/mercure'); 88 | 89 | // Messages to publish 90 | $messages = [ 91 | '/foo' => $uuids[0], 92 | '/bar' => $uuids[1], 93 | '/foobar/foobar' => $uuids[2], 94 | ]; 95 | 96 | $expectedReceivedEvents = [ 97 | $uuids[2] => [ 98 | 'data' => 'published on /foobar/foobar' 99 | ], 100 | ]; 101 | 102 | $receivedEvents = \array_map(fn() => ['data' => ''], $expectedReceivedEvents); 103 | 104 | foreach (publish($client, $publishUrl, $token, $messages) as $response) { 105 | $response->getContent(); 106 | } 107 | 108 | $eventSource = new EventSource($subscribeUrl, $loop); 109 | 110 | $stop = function () use ($eventSource, $loop) { 111 | $eventSource->close(); 112 | $loop->stop(); 113 | }; 114 | 115 | $eventSource->on( 116 | 'message', 117 | function (MessageEvent $message) use (&$receivedEvents, $expectedReceivedEvents, $stop) { 118 | $id = $message->lastEventId; 119 | $data = $message->data; 120 | $receivedEvents[$id] = ['data' => $data]; 121 | if (\count($receivedEvents) >= \count($expectedReceivedEvents)) { 122 | $stop(); 123 | } 124 | } 125 | ); 126 | 127 | $loop->addTimer(4, function () use ($stop) { 128 | $stop(); 129 | }); 130 | 131 | $loop->run(); 132 | 133 | assertCount(\count($receivedEvents), $expectedReceivedEvents); 134 | foreach ($expectedReceivedEvents as $id => $expectedEvent) { 135 | assertEquals($expectedEvent, $receivedEvents[$id]); 136 | } 137 | }); 138 | -------------------------------------------------------------------------------- /tests/Integration/PubSub/PubSubTest.php: -------------------------------------------------------------------------------- 1 | $value) { 27 | $builder = $builder->withClaim($name, $value); 28 | } 29 | 30 | static $signer; 31 | $signer ??= new Sha256(); 32 | 33 | return $builder->getToken($signer, new Key($key)); 34 | } 35 | 36 | function publish(HttpClientInterface $client, UriInterface $publishUrl, Token $token, iterable $messages) 37 | { 38 | foreach ($messages as $topic => $id) { 39 | $body = [ 40 | 'topic' => $topic, 41 | 'data' => \sprintf("published on %s", $topic), 42 | 'id' => $id, 43 | ]; 44 | 45 | yield $client->request( 46 | 'POST', 47 | $publishUrl, 48 | [ 49 | 'headers' => ['Authorization' => \sprintf("Bearer %s", $token)], 50 | 'body' => $body, 51 | 'user_data' => $id, 52 | ] 53 | ); 54 | } 55 | } 56 | 57 | $url = new Uri(sprintf("http://%s", $_SERVER['ADDR'])); 58 | $transport = $_SERVER['TRANSPORT_URL'] ?? null; 59 | $process = null; 60 | 61 | beforeAll(function () use ($transport, &$process) { 62 | if (null === $transport) { 63 | throw new \RuntimeException('Cannot run test, missing TRANSPORT_URL env var.'); 64 | } 65 | $process = new Process(['bin/mercure'], \dirname(__DIR__, 3)); 66 | $process->setTimeout(15); 67 | $process->setIdleTimeout(15); 68 | $process->start(); 69 | \sleep(1); 70 | }); 71 | 72 | afterAll(function () use (&$process) { 73 | $process->stop(1, \SIGINT); 74 | }); 75 | 76 | it('returns a 200 status code on health check', function () use ($url) { 77 | $response = HttpClient::create()->request('GET', $url->withPath('/.well-known/mercure/health')); 78 | assertEquals(200, $response->getStatusCode()); 79 | }); 80 | 81 | it('returns 404 on unknown urls', function () use ($url) { 82 | $response = HttpClient::create()->request('GET', $url->withPath('/foo')); 83 | assertEquals(404, $response->getStatusCode()); 84 | }); 85 | 86 | it('receives updates in real time', function () use ($url) { 87 | 88 | for ($uuids = [], $i = 1; $i <= 3; $i++) { 89 | $uuids[] = (string) Uuid::uuid4(); 90 | } 91 | 92 | $token = createJWT(['mercure' => ['publish' => ['*']]], $_SERVER['JWT_KEY']); 93 | $client = HttpClient::create(); 94 | $subscribeUrl = $url->withPath('/.well-known/mercure')->withQuery('topic=/foo&topic=/foobar/{id}'); 95 | $publishUrl = $url->withPath('/.well-known/mercure'); 96 | 97 | // Messages to publish 98 | $messages = [ 99 | '/foo' => $uuids[0], 100 | '/bar' => $uuids[1], 101 | '/foobar/foobar' => $uuids[2], 102 | ]; 103 | 104 | $expectedPublishResponse = [ 105 | $uuids[0] => $uuids[0], 106 | $uuids[1] => $uuids[1], 107 | $uuids[2] => $uuids[2], 108 | ]; 109 | 110 | $expectedReceivedEvents = [ 111 | $uuids[0] => [ 112 | 'data' => 'published on /foo' 113 | ], 114 | $uuids[2] => [ 115 | 'data' => 'published on /foobar/foobar' 116 | ], 117 | ]; 118 | 119 | $publishResponses = \array_map(fn() => '', $expectedPublishResponse); 120 | $receivedEvents = \array_map(fn() => ['data' => ''], $expectedReceivedEvents); 121 | 122 | $loop = Factory::create(); 123 | $eventSource = new EventSource($subscribeUrl, $loop); 124 | $eventSource->on('message', function (MessageEvent $message) use (&$receivedEvents) { 125 | $id = $message->lastEventId; 126 | $data = $message->data; 127 | $receivedEvents[$id] = ['data' => $data]; 128 | }); 129 | 130 | // Once subscribed, publish some messages 131 | $loop->addTimer(0.1, function () use ($client, $publishUrl, $token, $messages, &$publishResponses) { 132 | foreach (publish($client, $publishUrl, $token, $messages) as $response) { 133 | $content = $response->getContent(); 134 | $id = $response->getInfo('user_data'); 135 | $publishResponses[$id] = $content; 136 | } 137 | }); 138 | $loop->addTimer(0.5, fn() => $eventSource->close()); 139 | $loop->run(); 140 | 141 | foreach ($expectedPublishResponse as $id => $expectedResponse) { 142 | assertEquals($expectedResponse, $publishResponses[$id]); 143 | } 144 | 145 | assertCount(\count($receivedEvents), $expectedReceivedEvents); 146 | foreach ($expectedReceivedEvents as $id => $expectedEvent) { 147 | assertEquals($expectedEvent, $receivedEvents[$id]); 148 | } 149 | }); 150 | -------------------------------------------------------------------------------- /tests/Unit/Command/ServeCommandTest.php: -------------------------------------------------------------------------------- 1 | get(LoopInterface::class); 18 | $loop->addTimer(1.5, fn() => $loop->stop()); 19 | $configuration = (new Configuration())->overrideWith(without_nullish_values($_SERVER)); 20 | $factory = container()->get(HubFactoryInterface::class); 21 | $command = new ServeCommand($configuration, $factory, $loop, new NullLogger()); 22 | $tester = new CommandTester($command); 23 | $tester->execute([ 24 | '--jwt-key' => $_SERVER['JWT_KEY'], 25 | '--addr' => $_SERVER['ADDR'], 26 | ]); 27 | $output = $tester->getDisplay(); 28 | assertStringContainsString('[info] Server running at http://' . $_SERVER['ADDR'], $output); 29 | })->skip(); 30 | -------------------------------------------------------------------------------- /tests/Unit/Configuration/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | asArray(); 12 | })->throws(\InvalidArgumentException::class, "One of \"jwt_key\" or \"publisher_jwt_key\" configuration parameter must be defined."); 13 | 14 | it('doesn\'t yell if jwt key is set', function () { 15 | $config = (new Configuration()) 16 | ->overrideWith(['jwt_key' => 'foo']) 17 | ->asArray(); 18 | assertArrayHasKey('jwt_key', $config); 19 | assertEquals('foo', $config[Configuration::JWT_KEY]); 20 | }); 21 | 22 | it('doesn\'t yell if publisher jwt key is set', function () { 23 | $config = (new Configuration()) 24 | ->overrideWith(['publisher_jwt_key' => 'foo']) 25 | ->asArray(); 26 | assertArrayHasKey('publisher_jwt_key', $config); 27 | assertEquals('foo', $config[Configuration::PUBLISHER_JWT_KEY]); 28 | }); 29 | 30 | it('handles screaming snake case', function () { 31 | $config = (new Configuration()) 32 | ->overrideWith(['PUBLISHER_JWT_KEY' => 'foo']) 33 | ->asArray(); 34 | assertArrayHasKey('publisher_jwt_key', $config); 35 | assertEquals('foo', $config[Configuration::PUBLISHER_JWT_KEY]); 36 | }); 37 | 38 | it('handles kebab case as well', function () { 39 | $config = (new Configuration()) 40 | ->overrideWith(['PUBLISHER-JWT-KEY' => 'foo']) 41 | ->asArray(); 42 | assertArrayHasKey('publisher_jwt_key', $config); 43 | assertEquals('foo', $config[Configuration::PUBLISHER_JWT_KEY]); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/Unit/Controller/HealthControllerTest.php: -------------------------------------------------------------------------------- 1 | matchRequest($request)); 20 | }); 21 | 22 | it('returns a successful response', function () { 23 | $loop = Factory::create(); 24 | $request = new ServerRequest('GET', '/.well-known/mercure/health'); 25 | $handle = new HealthController(); 26 | $response = await($handle($request), $loop); 27 | assertInstanceOf(ResponseInterface::class, $response); 28 | assertEquals(200, $response->getStatusCode()); 29 | assertEmpty((string) $response->getBody()); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/Unit/Controller/Publish/PublishControllerTest.php: -------------------------------------------------------------------------------- 1 | asArray(); 31 | 32 | return (new PublishController(new NullLogger())) 33 | ->withStorage(new NullStorage()) 34 | ->withTransport(new NullTransport()) 35 | ->withConfig($config) 36 | ; 37 | } 38 | 39 | function createJWT(array $claims, string $key, ?int $expires = null): Token 40 | { 41 | $expires ??= (new \DateTime('tomorrow'))->format('U'); 42 | $builder = (new Builder())->expiresAt($expires); 43 | 44 | foreach ($claims as $name => $value) { 45 | $builder = $builder->withClaim($name, $value); 46 | } 47 | 48 | static $signer; 49 | $signer ??= new Sha256(); 50 | 51 | return $builder->getToken($signer, new Key($key)); 52 | } 53 | 54 | function authenticate(ServerRequestInterface $request, Token $token): ServerRequestInterface 55 | { 56 | return $request->withHeader('Authorization', 'Bearer ' . $token); 57 | } 58 | 59 | function createPublishRequest(?array $postData = []): ServerRequestInterface 60 | { 61 | return (new ServerRequest('POST', '/.well-known/mercure', []))->withParsedBody($postData); 62 | } 63 | 64 | it('will respond to the Mercure publish url', function () { 65 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 66 | $request = new ServerRequest('POST', '/.well-known/mercure'); 67 | assertTrue($handle->matchRequest($request)); 68 | }); 69 | 70 | it('will not respond when requet method is not post', function () { 71 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 72 | $request = new ServerRequest('GET', '/.well-known/mercure'); 73 | assertFalse($handle->matchRequest($request)); 74 | }); 75 | 76 | # Authentication / Authorization 77 | it('yells when no authorization header is present', function () { 78 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 79 | $request = new ServerRequest('POST', '/.well-known/mercure'); 80 | $handle($request); 81 | })->throws(AccessDeniedHttpException::class, 'Invalid auth token.'); 82 | 83 | it('yells if token is not signed', function () { 84 | $token = createJWT(['foo' => 'bar'], 'foo'); 85 | $transport = new NullTransport(); 86 | $storage = new NullStorage(); 87 | $handle = createController(new Configuration(['jwt_key' => 'bar'])); 88 | $request = authenticate(createPublishRequest(), $token); 89 | $handle($request); 90 | })->throws(AccessDeniedHttpException::class, 'Invalid token signature.'); 91 | 92 | it('yells if token is expired', function () { 93 | $token = createJWT(['foo' => 'bar'], 'foo', (new \DateTime('yesterday'))->format('U')); 94 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 95 | $request = authenticate(createPublishRequest(), $token); 96 | $handle($request); 97 | })->throws(AccessDeniedHttpException::class, 'Your token has expired.'); 98 | 99 | it('yells if token has no mercure claim', function () { 100 | $token = createJWT(['foo' => 'bar'], 'foo'); 101 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 102 | $request = authenticate(createPublishRequest(), $token); 103 | $handle($request); 104 | })->throws( 105 | AccessDeniedHttpException::class, 106 | 'Provided auth token doesn\'t contain the "mercure" claim.' 107 | ); 108 | 109 | it('yells if publishing is not authorized', function (array $claims) { 110 | $token = createJWT($claims, 'foo'); 111 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 112 | $request = authenticate(createPublishRequest(), $token); 113 | $handle($request); 114 | }) 115 | ->throws( 116 | AccessDeniedHttpException::class, 117 | 'Your are not authorized to publish on this hub.' 118 | ) 119 | ->with(function () { 120 | yield [['mercure' => ['subscribe' => ['*']]]]; 121 | yield [['mercure' => ['publish' => null]]]; 122 | yield [['mercure' => ['publish' => '*']]]; 123 | }); 124 | 125 | # Input validation 126 | it('yells if topic is invalid', function (?array $postData = null) { 127 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 128 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 129 | $request = authenticate(createPublishRequest($postData), $token); 130 | $handle($request); 131 | }) 132 | ->throws( 133 | BadRequestHttpException::class, 134 | 'Invalid topic parameter.' 135 | ) 136 | ->with(function () { 137 | yield []; 138 | yield [['data' => 'foo']]; 139 | yield [['topic' => ['foo']]]; 140 | yield [['topic' => null]]; 141 | }); 142 | 143 | it('yells if data is invalid', function (?array $postData = null) { 144 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 145 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 146 | $request = authenticate(createPublishRequest($postData), $token); 147 | $handle($request); 148 | }) 149 | ->throws( 150 | BadRequestHttpException::class, 151 | 'Invalid data parameter.' 152 | ) 153 | ->with(function () { 154 | yield [['topic' => '/foo', 'data' => []]]; 155 | yield [['topic' => '/foo', 'data' => new \stdClass()]]; 156 | }); 157 | 158 | it('yells when trying to dispatch an unauthorized private update', function (?array $postData = null) { 159 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 160 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 161 | $request = authenticate(createPublishRequest($postData), $token); 162 | $handle($request); 163 | }) 164 | ->throws( 165 | AccessDeniedHttpException::class, 166 | 'You are not allowed to dispatch private updates.' 167 | ) 168 | ->with(function () { 169 | yield [['topic' => '/foo', 'data' => 'foo', 'private' => true]]; 170 | }); 171 | 172 | it('yells when trying to dispatch an unauthorized update', function (?array $postData = null) { 173 | $token = createJWT(['mercure' => ['publish' => ['/foo/bar'], 'publish_exclude' => ['/foo/{id}']]], 'foo'); 174 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 175 | $request = authenticate(createPublishRequest($postData), $token); 176 | $handle($request); 177 | }) 178 | ->throws( 179 | AccessDeniedHttpException::class, 180 | 'You are not allowed to update this topic.' 181 | ) 182 | ->with(function () { 183 | yield [['topic' => '/foo/bar', 'data' => 'foo']]; 184 | }); 185 | 186 | it('publishes an update to the hub', function () { 187 | $loop = Factory::create(); 188 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 189 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 190 | $request = authenticate(createPublishRequest(['topic' => '/foo', 'data' => 'bar']), $token); 191 | $response = await($handle($request), $loop); 192 | assertInstanceOf(ResponseInterface::class, $response); 193 | assertEquals(201, $response->getStatusCode()); 194 | assertTrue(Uuid::isValid((string) $response->getBody())); 195 | }); 196 | 197 | it('accepts an UUID from client', function () { 198 | $loop = Factory::create(); 199 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 200 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 201 | $id = (string) Uuid::uuid4(); 202 | $request = authenticate(createPublishRequest(['topic' => '/foo', 'data' => 'bar', 'id' => $id]), $token); 203 | $response = await($handle($request), $loop); 204 | $content = (string) $response->getBody(); 205 | assertInstanceOf(ResponseInterface::class, $response); 206 | assertEquals(201, $response->getStatusCode()); 207 | assertTrue(Uuid::isValid($content)); 208 | assertEquals($id, $content); 209 | }); 210 | 211 | it('yells if client sends an invalid UUID', function () { 212 | $token = createJWT(['mercure' => ['publish' => []]], 'foo'); 213 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 214 | $id = 'foobar'; 215 | $request = authenticate(createPublishRequest(['topic' => '/foo', 'data' => 'bar', 'id' => $id]), $token); 216 | $handle($request); 217 | })->throws(BadRequestHttpException::class, 'Invalid UUID.'); 218 | -------------------------------------------------------------------------------- /tests/Unit/Controller/Subscribe/SubscribeControllerTest.php: -------------------------------------------------------------------------------- 1 | asArray(); 29 | 30 | return (new SubscribeController(Factory::create(), new NullLogger())) 31 | ->withTransport(new NullTransport()) 32 | ->withStorage(new NullStorage()) 33 | ->withConfig($config) 34 | ; 35 | } 36 | 37 | function createJWT(array $claims, string $key, ?int $expires = null): Token 38 | { 39 | $expires ??= (new \DateTime('tomorrow'))->format('U'); 40 | $builder = (new Builder())->expiresAt($expires); 41 | 42 | foreach ($claims as $name => $value) { 43 | $builder = $builder->withClaim($name, $value); 44 | } 45 | 46 | static $signer; 47 | $signer ??= new Sha256(); 48 | 49 | return $builder->getToken($signer, new Key($key)); 50 | } 51 | 52 | function authenticate(ServerRequestInterface $request, Token $token): ServerRequestInterface 53 | { 54 | return $request->withHeader('Authorization', 'Bearer ' . $token); 55 | } 56 | 57 | function createSubscribeRequest(array $subscribedTopics = ['/lobby']): ServerRequestInterface 58 | { 59 | $uri = new Uri('/.well-known/mercure'); 60 | $uri = $uri->withQuery( 61 | implode('&', \array_map(fn (string $topic) => 'topic=' . $topic, $subscribedTopics)) 62 | ); 63 | return (new ServerRequest('GET', $uri)); 64 | } 65 | 66 | it('will respond to the Mercure publish url', function () { 67 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 68 | $request = new ServerRequest('GET', '/.well-known/mercure'); 69 | assertTrue($handle->matchRequest($request)); 70 | }); 71 | 72 | it('will not respond when requet method is not get', function () { 73 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 74 | $request = new ServerRequest('POST', '/.well-known/mercure'); 75 | assertFalse($handle->matchRequest($request)); 76 | }); 77 | 78 | # Authentication / Authorization 79 | it('yells when anonymous subscriptions are not allowed and no auth is provided', function () { 80 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 81 | $request = createSubscribeRequest(); 82 | $handle($request); 83 | })->throws( 84 | AccessDeniedHttpException::class, 85 | 'Anonymous subscriptions are not allowed on this hub.' 86 | ); 87 | 88 | it('doesn\'t yell when anonymous subscriptions are allowed', function () { 89 | $handle = createController(new Configuration(['jwt_key' => 'foo', 'allow_anonymous' => true])); 90 | $request = createSubscribeRequest(); 91 | $handle($request); 92 | assertTrue(true); 93 | }); 94 | 95 | it('yells if token is not signed', function () { 96 | $token = createJWT(['foo' => 'bar'], 'foo'); 97 | $handle = createController(new Configuration(['jwt_key' => 'bar'])); 98 | $request = authenticate(createSubscribeRequest(), $token); 99 | $handle($request); 100 | })->throws(AccessDeniedHttpException::class, 'Invalid token signature.'); 101 | 102 | it('yells if token is expired', function () { 103 | $token = createJWT(['foo' => 'bar'], 'foo', (new \DateTime('yesterday'))->format('U')); 104 | $handle = createController(new Configuration(['jwt_key' => 'foo'])); 105 | $request = authenticate(createSubscribeRequest(), $token); 106 | $handle($request); 107 | })->throws(AccessDeniedHttpException::class, 'Your token has expired.'); 108 | 109 | it('creates an event stream response', function () { 110 | $loop = Factory::create(); 111 | $handle = createController(new Configuration(['jwt_key' => 'foo', 'allow_anonymous' => true])); 112 | $request = createSubscribeRequest(); 113 | $response = await($handle($request), $loop); 114 | assertEquals('text/event-stream', $response->getHeaderLine('Content-Type')); 115 | }); 116 | 117 | it('throws a 400 Bad request if no topic is provided', function () { 118 | $handle = createController(new Configuration(['jwt_key' => 'foo', 'allow_anonymous' => true])); 119 | $request = createSubscribeRequest([]); 120 | $response = $handle($request); 121 | })->throws(BadRequestHttpException::class, 'Missing "topic" parameter.'); 122 | -------------------------------------------------------------------------------- /tests/Unit/Helpers/QueryStringParsertest.php: -------------------------------------------------------------------------------- 1 | parse($queryString); 14 | assertArrayHasKey('topic', $params); 15 | assertIsArray($params['topic']); 16 | assertEquals($params['topic'], $expected); 17 | }) 18 | ->with(function () { 19 | yield ['topic=foo', ['foo']]; 20 | yield ['topic=foo&topic=bar', ['foo', 'bar']]; 21 | }); 22 | -------------------------------------------------------------------------------- /tests/Unit/Hub/HubFactoryTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 20 | Configuration::TRANSPORT_URL => 'null://localhost', 21 | ]); 22 | $loop = Factory::create(); 23 | $factory = new HubFactory( 24 | $config, 25 | $loop, 26 | new NullLogger(), 27 | new TransportFactory([]), 28 | new NullStorageFactory(), 29 | new PHPMetricsHandlerFactory(), 30 | [] 31 | ); 32 | $factory->create(); 33 | }) 34 | ->throws( 35 | \RuntimeException::class, 36 | 'Invalid transport DSN null://localhost' 37 | ); 38 | 39 | it('creates a hub otherwise', function () { 40 | $config = new Configuration([ 41 | Configuration::JWT_KEY => 'foo', 42 | Configuration::TRANSPORT_URL => 'null://localhost', 43 | Configuration::METRICS_URL => 'php://localhost', 44 | ]); 45 | $loop = Factory::create(); 46 | $hub = (new HubFactory( 47 | $config, 48 | $loop, 49 | new NullLogger(), 50 | new NullTransportFactory(), 51 | new NullStorageFactory(), 52 | new PHPMetricsHandlerFactory(), 53 | [] 54 | ))->create(); 55 | assertInstanceOf(Hub::class, $hub); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/Unit/Hub/HubTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 24 | Configuration::TRANSPORT_URL => 'null://localhost', 25 | Configuration::STORAGE_URL => 'null://localhost', 26 | Configuration::METRICS_URL => 'php://localhost', 27 | ] 28 | ); 29 | 30 | /** @var Hub $hub */ 31 | $hub = (new HubFactory( 32 | $config, 33 | container()->get(LoopInterface::class), 34 | new NullLogger(), 35 | new NullTransportFactory(), 36 | new NullStorageFactory(), 37 | new PHPMetricsHandlerFactory(), 38 | container()->get(ServicesByTagLocator::class)->getServicesByTag('mercure.controller') 39 | ))->create(); 40 | 41 | 42 | it('returns 200 when asking for health', function () use ($hub) { 43 | $request = new ServerRequest('GET', '/.well-known/mercure/health'); 44 | $response = await($hub($request), Factory::create()); 45 | assertEquals(200, $response->getStatusCode()); 46 | }); 47 | 48 | it('returns 404 when resource is not found', function () use ($hub) { 49 | $request = new ServerRequest('GET', '/foo'); 50 | $response = await($hub($request), Factory::create()); 51 | assertEquals(404, $response->getStatusCode()); 52 | }); 53 | 54 | it('returns 403 when not allowed to publish', function () use ($hub) { 55 | $request = new ServerRequest('POST', '/.well-known/mercure'); 56 | $response = await($hub($request), Factory::create()); 57 | assertEquals(403, $response->getStatusCode()); 58 | assertEquals('Invalid auth token.', (string) $response->getBody()); 59 | }); 60 | 61 | it('returns 403 when not allowed to subscribe', function () use ($hub) { 62 | $request = new ServerRequest('GET', '/.well-known/mercure'); 63 | $response = await($hub($request), Factory::create()); 64 | assertEquals(403, $response->getStatusCode()); 65 | assertEquals('Anonymous subscriptions are not allowed on this hub.', (string) $response->getBody()); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/Unit/Hub/RequestHandlerTest.php: -------------------------------------------------------------------------------- 1 | getMethod() && '/foo' === $request->getUri()->getPath(); 29 | } 30 | }, 31 | new class extends AbstractController { 32 | public function __invoke(ServerRequestInterface $request): PromiseInterface 33 | { 34 | return resolve(new Response(200, [], 'bar')); 35 | } 36 | 37 | public function matchRequest(RequestInterface $request): bool 38 | { 39 | return 'POST' === $request->getMethod() && '/foo' === $request->getUri()->getPath(); 40 | } 41 | }, 42 | new class extends AbstractController { 43 | public function __invoke(ServerRequestInterface $request): PromiseInterface 44 | { 45 | throw new BadRequestHttpException('Nope.'); 46 | } 47 | 48 | public function matchRequest(RequestInterface $request): bool 49 | { 50 | return '/bad' === $request->getUri()->getPath(); 51 | } 52 | }, 53 | ]; 54 | 55 | it('calls the appropriate controller', function () use ($controllers) { 56 | $loop = Factory::create(); 57 | $requestHandler = new RequestHandler($controllers); 58 | $request = new ServerRequest('POST', '/foo'); 59 | $response = await($requestHandler->handle($request), $loop); 60 | assertSame(200, $response->getStatusCode()); 61 | assertSame('bar', (string) $response->getBody()); 62 | }); 63 | 64 | it('converts HttpExceptions to response objects', function () use ($controllers) { 65 | $loop = Factory::create(); 66 | $requestHandler = new RequestHandler($controllers); 67 | $request = new ServerRequest('POST', '/bad'); 68 | $response = await($requestHandler->handle($request), $loop); 69 | assertSame(400, $response->getStatusCode()); 70 | assertSame('Nope.', (string) $response->getBody()); 71 | }); 72 | 73 | it('responds 404 when no controller can handle the request', function () use ($controllers) { 74 | $loop = Factory::create(); 75 | $requestHandler = new RequestHandler($controllers); 76 | $request = new ServerRequest('POST', '/unknown'); 77 | $response = await($requestHandler->handle($request), $loop); 78 | assertSame(404, $response->getStatusCode()); 79 | assertSame('Not found.', (string) $response->getBody()); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/Unit/Model/MessageTest.php: -------------------------------------------------------------------------------- 1 | [ 13 | 'foo', 14 | ], 15 | 'data' => [ 16 | null, 17 | 'foobar', 18 | << [ 26 | true, 27 | false, 28 | ], 29 | 'type' => [ 30 | null, 31 | 'test', 32 | ], 33 | 'retry' => [ 34 | null, 35 | 10, 36 | ], 37 | ]); 38 | 39 | it('instanciates a message', function (string $id, ?string $data, bool $private, ?string $event, ?int $retry) { 40 | $message = new Message($id, $data, $private, $event, $retry); 41 | assertSame($id, $message->getId()); 42 | assertSame($data, $message->getData()); 43 | assertSame($private, $message->isPrivate()); 44 | })->with($combinations); 45 | 46 | it('produces the expected JSON', function (string $id, ?string $data, bool $private, ?string $event, ?int $retry) { 47 | $message = new Message($id, $data, $private, $event, $retry); 48 | assertInstanceOf(\JsonSerializable::class, $message); 49 | 50 | $expected = [ 51 | 'id' => $id, 52 | ]; 53 | 54 | if (null !== $data) { 55 | $expected['data'] = $data; 56 | } 57 | 58 | $expected['private'] = $private; 59 | 60 | if (null !== $event) { 61 | $expected['event'] = $event; 62 | } 63 | 64 | if (null !== $retry) { 65 | $expected['retry'] = $retry; 66 | } 67 | 68 | assertSame($expected, $message->jsonSerialize()); 69 | })->with($combinations); 70 | 71 | it('produces the expected string', function (string $id, ?string $data, bool $private, ?string $event, ?int $retry) { 72 | $message = new Message($id, $data, $private, $event, $retry); 73 | assertInstanceOf(\JsonSerializable::class, $message); 74 | 75 | $expected = sprintf('id:%s%s', $id, \PHP_EOL); 76 | if (null !== $event) { 77 | $expected .= sprintf('event:%s%s', $event, \PHP_EOL); 78 | } 79 | if (null !== $retry) { 80 | $expected .= sprintf('retry:%d%s', $retry, \PHP_EOL); 81 | } 82 | 83 | $multiline = <<with($combinations); 105 | -------------------------------------------------------------------------------- /tests/Unit/Security/AuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | withClaim('mercure', $mercureClaim); 29 | 30 | return $builder->getToken($signer, $key); 31 | } 32 | 33 | [$public, $private] = Shh::generateKeyPair(); 34 | 35 | it('can authenticate from an Authorization header', function (Signer $signer, Key $private, Key $public) { 36 | $request = new ServerRequest( 37 | 'GET', 38 | '/', 39 | [ 40 | 'Authorization' => 'Bearer ' . createJWT($private, [], $signer), 41 | ] 42 | ); 43 | $authenticator = createAuthenticator($public, $signer); 44 | $token = $authenticator->authenticate($request); 45 | assertTrue($token instanceof Token); 46 | assertTrue($token->verify($signer, $public)); 47 | })->with(function () use ($public, $private) { 48 | yield [new Signer\Hmac\Sha256(), new Key('foo'), new Key('foo')]; 49 | yield [new Signer\Rsa\Sha512(), new Key($private), new Key($public)]; 50 | }); 51 | 52 | it('can authenticate from an Cookie header', function (Signer $signer, Key $private, Key $public) { 53 | $request = (new ServerRequest('GET', '/')) 54 | ->withCookieParams(['mercureAuthorization' => createJWT($private, [], $signer)]); 55 | $authenticator = createAuthenticator($public, $signer); 56 | $token = $authenticator->authenticate($request); 57 | assertTrue($token instanceof Token); 58 | assertTrue($token->verify($signer, $public)); 59 | })->with(function () use ($public, $private) { 60 | yield [new Signer\Hmac\Sha256(), new Key('foo'), new Key('foo')]; 61 | yield [new Signer\Rsa\Sha512(), new Key($private), new Key($public)]; 62 | }); 63 | 64 | it('prefers Authorization header over Cookie', function (Signer $signer, Key $private, Key $public) { 65 | $request = (new ServerRequest('GET', '/', [ 66 | 'Authorization' => 'Bearer ' . createJWT($private, ['bar'], $signer) 67 | ])) 68 | ->withCookieParams(['mercureAuthorization' => createJWT($private, ['baz'], $signer)]); 69 | $authenticator = createAuthenticator($public, $signer); 70 | $token = $authenticator->authenticate($request); 71 | assertTrue($token instanceof Token); 72 | assertTrue($token->verify($signer, $public)); 73 | assertContains('bar', $token->getClaim('mercure')); 74 | })->with(function () use ($public, $private) { 75 | yield [new Signer\Hmac\Sha256(), new Key('foo'), new Key('foo')]; 76 | yield [new Signer\Rsa\Sha512(), new Key($private), new Key($public)]; 77 | }); 78 | 79 | $signers = [ 80 | 'HS256' => new Signer\Hmac\Sha256(), 81 | 'RS512' => new Signer\Rsa\Sha512(), 82 | ]; 83 | 84 | $combinations = cartesian_product( 85 | [ 86 | 'default_algo' => [ 87 | null, 88 | 'HS256', 89 | 'RS512' 90 | ], 91 | 'subscriber_algo' => [ 92 | null, 93 | 'HS256', 94 | 'RS512' 95 | ], 96 | 'default_key' => [ 97 | null, 98 | function (array $combination) { 99 | $algo = $combination['subscriber_algo'] 100 | ?? $combination['default_algo'] 101 | ?? 'HS256'; 102 | 103 | if ('HS256' === $algo) { 104 | return ['default', 'default']; 105 | } 106 | 107 | [$public, $private] = Shh::generateKeyPair(); 108 | 109 | return [$public, $private]; 110 | } 111 | ], 112 | 'subscriber_key' => [ 113 | null, 114 | function (array $combination) { 115 | $algo = $combination['subscriber_algo'] 116 | ?? $combination['default_algo'] 117 | ?? 'HS256'; 118 | 119 | if ('HS256' === $algo) { 120 | return ['subscriber', 'subscriber']; 121 | } 122 | 123 | [$public, $private] = Shh::generateKeyPair(); 124 | 125 | return [$public, $private]; 126 | } 127 | ], 128 | ] 129 | ); 130 | 131 | it('creates the subscriber authenticator', function ( 132 | ?string $defaultAlgo, 133 | ?string $subscriberAlgo, 134 | ?array $defaultKeyPair, 135 | ?array $subscriberKeyPair 136 | ) use ($signers) { 137 | 138 | $config = []; 139 | 140 | if (null !== $defaultAlgo) { 141 | $config[Configuration::JWT_ALGORITHM] = $defaultAlgo; 142 | } 143 | 144 | if (null !== $subscriberAlgo) { 145 | $config[Configuration::SUBSCRIBER_JWT_ALGORITHM] = $subscriberAlgo; 146 | } 147 | 148 | if (null !== $defaultKeyPair) { 149 | $config[Configuration::JWT_KEY] = $defaultKeyPair[0]; 150 | } 151 | 152 | if (null !== $subscriberKeyPair) { 153 | $config[Configuration::SUBSCRIBER_JWT_KEY] = $subscriberKeyPair[0]; 154 | } 155 | 156 | $config = new Configuration($config); 157 | $authenticator = Authenticator::createSubscriberAuthenticator($config->asArray()); 158 | $keyPair = $subscriberKeyPair ?? $defaultKeyPair; 159 | $algo = $subscriberAlgo ?? $defaultAlgo ?? 'HS256'; 160 | 161 | $token = createJWT(new Key($keyPair[1]), [], $signers[$algo]); 162 | $request = (new ServerRequest('GET', '/')) 163 | ->withHeader('Authorization', 'Bearer ' . $token); 164 | assertInstanceOf(Token::class, $authenticator->authenticate($request)); 165 | })->with( 166 | (new FilterIterator( 167 | $combinations, 168 | fn (array $combination) => null !== $combination['default_key'] 169 | && null !== $combination['subscriber_key'] 170 | )) 171 | ); 172 | 173 | $combinations = cartesian_product( 174 | [ 175 | 'default_algo' => [ 176 | null, 177 | 'HS256', 178 | 'RS512' 179 | ], 180 | 'publisher_algo' => [ 181 | null, 182 | 'HS256', 183 | 'RS512' 184 | ], 185 | 'default_key' => [ 186 | null, 187 | function (array $combination) { 188 | $algo = $combination['publisher_algo'] 189 | ?? $combination['default_algo'] 190 | ?? 'HS256'; 191 | 192 | if ('HS256' === $algo) { 193 | return ['default', 'default']; 194 | } 195 | 196 | [$public, $private] = Shh::generateKeyPair(); 197 | 198 | return [$public, $private]; 199 | } 200 | ], 201 | 'publisher_key' => [ 202 | null, 203 | function (array $combination) { 204 | $algo = $combination['publisher_algo'] 205 | ?? $combination['default_algo'] 206 | ?? 'HS256'; 207 | 208 | if ('HS256' === $algo) { 209 | return ['publisher', 'publisher']; 210 | } 211 | 212 | [$public, $private] = Shh::generateKeyPair(); 213 | 214 | return [$public, $private]; 215 | } 216 | ], 217 | ] 218 | ); 219 | 220 | it('creates the publisher authenticator', function ( 221 | ?string $defaultAlgo, 222 | ?string $publisherAlgo, 223 | ?array $defaultKeyPair, 224 | ?array $publisherKeyPair 225 | ) use ($signers) { 226 | 227 | $config = []; 228 | 229 | if (null !== $defaultAlgo) { 230 | $config[Configuration::JWT_ALGORITHM] = $defaultAlgo; 231 | } 232 | 233 | if (null !== $publisherAlgo) { 234 | $config[Configuration::PUBLISHER_JWT_ALGORITHM] = $publisherAlgo; 235 | } 236 | 237 | if (null !== $defaultKeyPair) { 238 | $config[Configuration::JWT_KEY] = $defaultKeyPair[0]; 239 | } 240 | 241 | if (null !== $publisherKeyPair) { 242 | $config[Configuration::PUBLISHER_JWT_KEY] = $publisherKeyPair[0]; 243 | } 244 | 245 | $config = new Configuration($config); 246 | $authenticator = Authenticator::createPublisherAuthenticator($config->asArray()); 247 | $keyPair = $publisherKeyPair ?? $defaultKeyPair; 248 | $algo = $publisherAlgo ?? $defaultAlgo ?? 'HS256'; 249 | 250 | $token = createJWT(new Key($keyPair[1]), [], $signers[$algo]); 251 | $request = (new ServerRequest('GET', '/')) 252 | ->withHeader('Authorization', 'Bearer ' . $token); 253 | assertInstanceOf(Token::class, $authenticator->authenticate($request)); 254 | })->with( 255 | (new FilterIterator( 256 | $combinations, 257 | fn (array $combination) => null !== $combination['default_key'] 258 | && null !== $combination['publisher_key'] 259 | )) 260 | ); 261 | -------------------------------------------------------------------------------- /tests/Unit/Security/CORSTest.php: -------------------------------------------------------------------------------- 1 | withHeader($headerName, $origin); 19 | } 20 | 21 | return $request; 22 | } 23 | 24 | function createResponse(): Response 25 | { 26 | return new Response(200); 27 | } 28 | 29 | $combinations = cartesian_product( 30 | [ 31 | 'method' => [ 32 | 'GET', 33 | 'POST', 34 | ], 35 | 'origin' => [ 36 | 'http://www.example.com', 37 | 'https://good.example.com', 38 | 'http://good.example.com', 39 | 'http://bad.example.com', 40 | null, 41 | ], 42 | 'header_name' => [ 43 | 'Origin', 44 | 'Referer', 45 | ], 46 | 'cors_allowed_origins' => [ 47 | '*', 48 | 'http://example.com', 49 | 'http://www.example.com', 50 | 'http://www.example.com,https://good.example.com', 51 | 'http://www.example.com;https://good.example.com', 52 | 'http://www.example.com https://good.example.com', 53 | ], 54 | 'publish_allowed_origins' => [ 55 | '*', 56 | 'http://example.com', 57 | 'http://www.example.com', 58 | 'http://www.example.com,https://good.example.com', 59 | 'http://www.example.com;https://good.example.com', 60 | 'http://www.example.com https://good.example.com', 61 | ], 62 | 'expected' => [ 63 | static function (array $combination) { 64 | $config = 'POST' === $combination['method'] ? $combination['publish_allowed_origins'] : $combination['cors_allowed_origins']; 65 | $matchAll = false !== \strpos($config, '*'); 66 | 67 | // No origin provided -> no header 68 | if (null === $combination['origin']) { 69 | return null; 70 | } 71 | 72 | // All origins allowed -> return provided origin 73 | if ($matchAll) { 74 | return $combination['origin']; 75 | } 76 | 77 | // Otherwise, check if origin is explicitely listed 78 | return false !== \strpos($config, $combination['origin']) ? $combination['origin'] : ''; 79 | }, 80 | ], 81 | ] 82 | ); 83 | 84 | it( 85 | 'returns the origin, and only when origin matches', 86 | function ( 87 | string $method, 88 | ?string $origin, 89 | string $headerName, 90 | string $allowedOrigins, 91 | ?string $publishAllowedOrigins, 92 | ?string $expected 93 | ) { 94 | $config = new Configuration( 95 | [ 96 | Configuration::JWT_KEY => 'foo', 97 | Configuration::CORS_ALLOWED_ORIGINS => $allowedOrigins, 98 | Configuration::PUBLISH_ALLOWED_ORIGINS => $publishAllowedOrigins, 99 | ] 100 | ); 101 | $request = createRequest($method, $headerName, $origin); 102 | $cors = new CORS($config->asArray()); 103 | $response = $cors->decorateResponse($request, createResponse()); 104 | assertEquals($expected, $response->getHeaderLine('Access-Control-Allow-Origin')); 105 | assertEquals(200, $response->getStatusCode()); 106 | } 107 | )->with($combinations); 108 | -------------------------------------------------------------------------------- /tests/Unit/Security/TopicMatcher/Publish/TopicMatcherTest.php: -------------------------------------------------------------------------------- 1 | withClaim('mercure', $mercureClaim) 21 | ->getToken(new Sha256(), new Key($key)) 22 | ; 23 | } 24 | 25 | $counter = 0; 26 | $combinations = combinations([ 27 | 'topic' => [ 28 | '/foo/bar', 29 | '/foo/baz', 30 | ], 31 | 'token' => [ 32 | (new Builder())->withClaim('foo', [])->getToken(), 33 | createMercureJWT('secret_key', []), 34 | createMercureJWT('secret_key', ['publish' => null]), 35 | createMercureJWT('secret_key', ['publish' => []]), 36 | createMercureJWT('secret_key', ['publish' => ['*']]), 37 | createMercureJWT('secret_key', ['publish' => ['/foo/bar']]), 38 | createMercureJWT('secret_key', ['publish' => ['/foo/{id}']]), 39 | createMercureJWT('secret_key', ['publish' => ['/foo/{id}'], 'publish_exclude' => ['/foo/bar']]), 40 | createMercureJWT('secret_key', ['publish' => ['/foo/bar'], 'publish_exclude' => ['/foo/bar']]), 41 | createMercureJWT('secret_key', ['publish' => ['/foo/bar'], 'publish_exclude' => ['/foo/{id}']]), 42 | createMercureJWT('secret_key', ['publish' => ['/foo/bar'], 'publish_exclude' => ['*']]), 43 | ], 44 | 'private' => [ 45 | true, 46 | false, 47 | ], 48 | 'expected' => [ 49 | function (array $combination): bool { 50 | /** @var Token $token */ 51 | $token = $combination['token']; 52 | $claims = $token->getClaims(); 53 | if (!\array_key_exists('mercure', $claims)) { 54 | return false; 55 | } 56 | if (!isset($token->getClaim('mercure')['publish'])) { 57 | return false; 58 | } 59 | $included = $token->getClaim('mercure')['publish']; 60 | $excluded = $token->getClaim('mercure')['publish_exclude'] ?? []; 61 | $isIncluded = \in_array(reset($included), [$combination['topic'], '/foo/{id}', '*'], true); 62 | $isExcluded = \in_array(reset($excluded), [$combination['topic'], '/foo/{id}', '*'], true); 63 | $isNotExcluded = !$isExcluded; 64 | 65 | return false === $combination['private'] ? $isNotExcluded : $isIncluded && $isNotExcluded; 66 | }, 67 | ], 68 | ]); 69 | 70 | test( 71 | 'Topic can be updated only when authorized', 72 | function (string $topic, Token $token, bool $private, bool $expected) use (&$counter) { 73 | $result = TopicMatcher::canUpdateTopic($topic, $token, $private); 74 | assertSame($expected, $result); 75 | $counter++; 76 | } 77 | )->with($combinations->asArray()); 78 | 79 | test('all combinations have been tested', function () use ($combinations, &$counter) { 80 | assertEquals(\count($combinations), $counter); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/Unit/Security/TopicMatcher/Subscribe/TopicMatcherTest.php: -------------------------------------------------------------------------------- 1 | withClaim('mercure', $mercureClaim) 22 | ->getToken(new Sha256(), new Key($key)) 23 | ; 24 | } 25 | 26 | test('the * selector opens all the gates', function () { 27 | $token = createMercureJWT('foo', ['subscribe' => ['*']]); 28 | assertTrue(TopicMatcher::matchesTopicSelectors('/foo', ['*'])); 29 | assertTrue(TopicMatcher::matchesTopicSelectors('/foo/{bar}', ['*'])); 30 | assertTrue(TopicMatcher::canSubscribeToTopic('/foo', $token, false)); 31 | assertTrue(TopicMatcher::canReceiveUpdate('/foo', new Message('foo'), ['/foo'], $token, false)); 32 | }); 33 | 34 | test('some topics can be excluded', function () { 35 | $token = createMercureJWT('foo', [ 36 | 'subscribe' => ['*'], 37 | 'subscribe_exclude' => ['/bar'], 38 | ]); 39 | 40 | assertTrue(TopicMatcher::canSubscribeToTopic('/foo', $token, false)); 41 | assertFalse(TopicMatcher::canSubscribeToTopic('/bar', $token, false)); 42 | }); 43 | 44 | /** 45 | * All the following tests are: 46 | * - Subscribe to "/alice", "/bob", "/channels/{whatever}" and "/admins/{id}" 47 | * - Either anonymous, or JWT allowing "/alice" and "/channels/{whatever}" only. 48 | */ 49 | $combinations = combinations( 50 | [ 51 | 'subscribe' => [ 52 | [ 53 | '/alice', 54 | '/bob', 55 | '/channels/{channel}', 56 | '/admins/{id}', 57 | ] 58 | ], 59 | 'allow_anonymous' => [ 60 | true, 61 | false, 62 | ], 63 | 'token' => [ 64 | null, 65 | createMercureJWT('JWT_KEY', [ 66 | 'subscribe' => [ 67 | '/alice', 68 | '/channels/{channel}', 69 | ] 70 | ]), 71 | ], 72 | 'private' => [ 73 | false, 74 | true, 75 | ], 76 | ] 77 | ); 78 | 79 | $counter = 0; 80 | 81 | test( 82 | 'When anonymous are allowed and token is null', 83 | function (array $subscribedTopics, bool $allowAnonymous, ?Token $token, bool $private) use (&$counter) { 84 | $message = new Message('foo', 'bar', $private); 85 | 86 | assertTrue(TopicMatcher::canSubscribeToTopic($subscribedTopics[0], $token, $allowAnonymous)); 87 | assertTrue(TopicMatcher::canSubscribeToTopic($subscribedTopics[1], $token, $allowAnonymous)); 88 | $privateRecipients = [ 89 | '/alice' => false, // No token, no updates 90 | '/bob' => false, // No token, no updates 91 | '/unknown' => false, // Not subscribed 92 | '/channels/foo' => false, // No token, no updates 93 | '/admins/1' => false, // No token, no updates 94 | ]; 95 | $publicRecipients = [ 96 | '/alice' => true, // Subscribed 97 | '/bob' => true, // Subscribed 98 | '/unknown' => false, // Not subscribed 99 | '/channels/foo' => true, // Subscribed via template 100 | '/admins/1' => true, // Subscribed via template 101 | ]; 102 | $recipients = $message->isPrivate() ? $privateRecipients : $publicRecipients; 103 | foreach ($recipients as $topic => $expected) { 104 | assertEquals( 105 | $expected, 106 | TopicMatcher::canReceiveUpdate($topic, $message, $subscribedTopics, $token, $allowAnonymous) 107 | ); 108 | } 109 | 110 | $counter++; 111 | } 112 | ) 113 | ->with( 114 | (new FilterIterator($combinations)) 115 | ->filter(fn ($combination) => true === $combination['allow_anonymous']) 116 | ->filter(fn ($combination) => null === $combination['token']) 117 | ); 118 | 119 | test( 120 | 'When anonymous are allowed and token is valid', 121 | function (array $subscribedTopics, bool $allowAnonymous, ?Token $token, bool $private) use (&$counter) { 122 | $message = new Message('foo', 'bar', $private); 123 | 124 | assertTrue(TopicMatcher::canSubscribeToTopic($subscribedTopics[0], $token, $allowAnonymous)); 125 | assertTrue(TopicMatcher::canSubscribeToTopic($subscribedTopics[1], $token, $allowAnonymous)); 126 | $privateRecipients = [ 127 | '/alice' => true, // Token allows this topic 128 | '/bob' => false, // Token doesn't allow this topic 129 | '/unknown' => false, // Not subscribed 130 | '/channels/foo' => true, // URI template matches token claim 131 | '/admins/1' => false, // URI template doesn't match token claim 132 | ]; 133 | $publicRecipients = [ 134 | '/alice' => true, // Subscribed 135 | '/bob' => true, // Subscribed 136 | '/unknown' => false, // Not subscribed 137 | '/channels/foo' => true, // Subscribed via template 138 | '/admins/1' => true, // Subscribed via template 139 | ]; 140 | $recipients = $message->isPrivate() ? $privateRecipients : $publicRecipients; 141 | foreach ($recipients as $topic => $expected) { 142 | assertEquals( 143 | $expected, 144 | TopicMatcher::canReceiveUpdate($topic, $message, $subscribedTopics, $token, $allowAnonymous) 145 | ); 146 | } 147 | $counter++; 148 | } 149 | ) 150 | ->with( 151 | (new FilterIterator($combinations)) 152 | ->filter(fn ($combination) => true === $combination['allow_anonymous']) 153 | ->filter(fn ($combination) => null !== $combination['token']) 154 | ); 155 | 156 | test( 157 | 'When anonymous are not allowed and token is null', 158 | function (array $subscribedTopics, bool $allowAnonymous, ?Token $token, bool $private) use (&$counter) { 159 | $message = new Message('foo', 'bar', $private); 160 | 161 | assertFalse(TopicMatcher::canSubscribeToTopic($subscribedTopics[0], $token, $allowAnonymous)); 162 | assertFalse(TopicMatcher::canSubscribeToTopic($subscribedTopics[1], $token, $allowAnonymous)); 163 | $privateRecipients = [ 164 | '/alice' => false, // No token, no updates 165 | '/bob' => false, // No token, no updates 166 | '/unknown' => false, // Not subscribed 167 | '/channels/foo' => false, // No token, no updates 168 | '/admins/1' => false, // No token, no updates 169 | ]; 170 | $publicRecipients = [ 171 | '/alice' => false, // No token, no updates 172 | '/bob' => false, // No token, no updates 173 | '/unknown' => false, // Not subscribed 174 | '/channels/foo' => false, // No token, no updates 175 | '/admins/1' => false, // No token, no updates 176 | ]; 177 | $recipients = $message->isPrivate() ? $privateRecipients : $publicRecipients; 178 | foreach ($recipients as $topic => $expected) { 179 | assertEquals( 180 | $expected, 181 | TopicMatcher::canReceiveUpdate($topic, $message, $subscribedTopics, $token, $allowAnonymous) 182 | ); 183 | } 184 | $counter++; 185 | } 186 | ) 187 | ->with( 188 | (new FilterIterator($combinations)) 189 | ->filter(fn ($combination) => false === $combination['allow_anonymous']) 190 | ->filter(fn ($combination) => null === $combination['token']) 191 | ); 192 | 193 | test( 194 | 'When anonymous are not allowed and token is valid', 195 | function (array $subscribedTopics, bool $allowAnonymous, ?Token $token, bool $private) use (&$counter) { 196 | $message = new Message('foo', 'bar', $private); 197 | 198 | assertTrue(TopicMatcher::canSubscribeToTopic($subscribedTopics[0], $token, $allowAnonymous)); 199 | assertFalse(TopicMatcher::canSubscribeToTopic($subscribedTopics[1], $token, $allowAnonymous)); 200 | 201 | $privateRecipients = [ 202 | '/alice' => true, // Subscribed, authorized 203 | '/bob' => false, // Subscribed, not authorized 204 | '/unknown' => false, // Not subscribed 205 | '/channels/foo' => true, // Subscribed via template 206 | '/admins/1' => false, // Subscribed, but not authorized 207 | ]; 208 | $publicRecipients = [ 209 | '/alice' => true, // Subscribed, authorized 210 | '/bob' => true, // Subscribed, not authorized but public 211 | '/unknown' => false, // Not subscribed 212 | '/channels/foo' => true, // Subscribed via template 213 | '/admins/1' => true, // Subscribed via template 214 | ]; 215 | $recipients = $message->isPrivate() ? $privateRecipients : $publicRecipients; 216 | foreach ($recipients as $topic => $expected) { 217 | assertEquals( 218 | $expected, 219 | TopicMatcher::canReceiveUpdate($topic, $message, $subscribedTopics, $token, $allowAnonymous) 220 | ); 221 | } 222 | $counter++; 223 | } 224 | ) 225 | ->with( 226 | (new FilterIterator($combinations)) 227 | ->filter(fn ($combination) => false === $combination['allow_anonymous']) 228 | ->filter(fn ($combination) => null !== $combination['token']) 229 | ); 230 | 231 | test('all combinations have been tested', function () use ($combinations, &$counter) { 232 | assertEquals(\count($combinations), $counter); 233 | }); 234 | -------------------------------------------------------------------------------- /tests/Unit/Storage/PHP/PHPStorageFactoryTest.php: -------------------------------------------------------------------------------- 1 | supports('php://localhost')); 18 | assertTrue($factory->supports('php://localhost?size=0')); 19 | assertTrue($factory->supports('php://localhost?size=100')); 20 | }); 21 | 22 | it('doesn\'t support other schemes', function () { 23 | $factory = new PHPStorageFactory(); 24 | assertFalse($factory->supports('foo://localhost')); 25 | }); 26 | 27 | it('creates a storage instance', function () { 28 | $reflClass = new \ReflectionClass(PHPStorage::class); 29 | $reflProp = $reflClass->getProperty('size'); 30 | $reflProp->setAccessible(true); 31 | 32 | $loop = Factory::create(); 33 | $factory = new PHPStorageFactory(); 34 | 35 | $storage = await($factory->create('php://localhost'), $loop); 36 | assertInstanceOf(PHPStorage::class, $storage); 37 | assertEquals(0, $reflProp->getValue($storage)); 38 | 39 | $storage = await($factory->create('php://localhost?size=100'), $loop); 40 | assertInstanceOf(PHPStorage::class, $storage); 41 | assertEquals(100, $reflProp->getValue($storage)); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/Unit/Storage/PHP/PHPStorageTest.php: -------------------------------------------------------------------------------- 1 | $message) { 17 | $storage->storeMessage($topic, $message); 18 | } 19 | 20 | $reflClass = new \ReflectionClass($storage); 21 | $reflProp = $reflClass->getProperty('messages'); 22 | $reflProp->setAccessible(true); 23 | 24 | $storedMessages = $reflProp->getValue($storage); 25 | assertEquals($expected, $storedMessages); 26 | })->with(function () { 27 | $messages = [ 28 | '/foo' => new Message((string) Uuid::uuid4()), 29 | '/bar' => new Message((string) Uuid::uuid4()), 30 | '/baz' => new Message((string) Uuid::uuid4()), 31 | '/bat' => new Message((string) Uuid::uuid4()), 32 | ]; 33 | 34 | $expected = $messages; 35 | \array_walk($expected, function (Message &$message, string $topic) { 36 | $message = [$topic, $message]; 37 | }); 38 | $expected = \array_values($expected); 39 | 40 | yield [0, $messages, []]; 41 | yield [100, $messages, $expected]; 42 | yield [3, $messages, \array_slice($expected, 1, 3)]; 43 | }); 44 | 45 | it('retrieves missed messages', function () { 46 | $storage = new PHPStorage(100); 47 | 48 | $ids = [ 49 | (string) Uuid::uuid4(), 50 | (string) Uuid::uuid4(), 51 | (string) Uuid::uuid4(), 52 | (string) Uuid::uuid4(), 53 | ]; 54 | 55 | $messages = function () use ($ids) { 56 | yield '/foo' => new Message($ids[0]); 57 | yield '/foo' => new Message($ids[1]); 58 | yield '/baz' => new Message($ids[2]); 59 | yield '/bat' => new Message($ids[3]); 60 | }; 61 | 62 | $flatten = function (iterable $messages): array { 63 | $values = []; 64 | foreach ($messages as $message) { 65 | $values[] = $message; 66 | } 67 | 68 | return $values; 69 | }; 70 | 71 | foreach ($messages() as $topic => $message) { 72 | $storage->storeMessage($topic, $message); 73 | } 74 | 75 | $subscribedTopics = ['*']; 76 | $bucket = await($storage->retrieveMessagesAfterId($storage::EARLIEST, $subscribedTopics), Factory::create()); 77 | $received = []; 78 | foreach ($bucket as $topic => $message) { 79 | $received[] = $message; 80 | } 81 | 82 | assertEquals($received, $flatten($messages())); 83 | 84 | $subscribedTopics = ['*']; 85 | $bucket = await($storage->retrieveMessagesAfterId($ids[0], $subscribedTopics), Factory::create()); 86 | $received = []; 87 | foreach ($bucket as $topic => $message) { 88 | $received[] = $message; 89 | } 90 | 91 | assertEquals($received, \array_slice($flatten($messages()), 1, 3)); 92 | 93 | $subscribedTopics = ['/foo']; 94 | $bucket = await($storage->retrieveMessagesAfterId($storage::EARLIEST, $subscribedTopics), Factory::create()); 95 | $received = []; 96 | foreach ($bucket as $topic => $message) { 97 | $received[] = $message; 98 | } 99 | 100 | assertEquals($received, \array_slice($flatten($messages()), 0, 2)); 101 | 102 | $subscribedTopics = ['/foo']; 103 | $bucket = await($storage->retrieveMessagesAfterId($ids[0], $subscribedTopics), Factory::create()); 104 | $received = []; 105 | foreach ($bucket as $topic => $message) { 106 | $received[] = $message; 107 | } 108 | 109 | assertEquals($received, \array_slice($flatten($messages()), 1, 1)); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/Unit/Storage/Redis/RedisStorageFactoryTest.php: -------------------------------------------------------------------------------- 1 | supports('redis://localhost')); 19 | assertTrue($factory->supports('rediss://localhost')); 20 | assertTrue($factory->supports('redis://:foobar@localhost')); 21 | assertTrue($factory->supports('rediss://:foobar@localhost')); 22 | }); 23 | 24 | it('doesn\'t support other schemes', function () { 25 | $loop = Factory::create(); 26 | $factory = new RedisStorageFactory($loop, new NullLogger()); 27 | assertFalse($factory->supports('foo://localhost')); 28 | }); 29 | 30 | it('creates a storage instance', function () { 31 | $loop = Factory::create(); 32 | $factory = new RedisStorageFactory($loop, new NullLogger()); 33 | 34 | if (!$factory->supports($_SERVER['REDIS_DSN'])) { 35 | throw new \LogicException('Your Redis DSN is misconfigured in phpunit.xml.'); 36 | } 37 | 38 | $promise = $factory->create($_SERVER['REDIS_DSN']); 39 | $storage = await($promise, $loop); 40 | 41 | assertInstanceOf(RedisStorage::class, $storage); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/Unit/Storage/Redis/RedisStorageTest.php: -------------------------------------------------------------------------------- 1 | createClient($_SERVER['REDIS_DSN']), $loop); 20 | $syncClient = new Client($_SERVER['REDIS_DSN']); 21 | $storage = new RedisStorage($asyncClient, $syncClient); 22 | 23 | $ids = [ 24 | (string) Uuid::uuid4(), 25 | (string) Uuid::uuid4(), 26 | (string) Uuid::uuid4(), 27 | (string) Uuid::uuid4(), 28 | ]; 29 | 30 | $messages = function () use ($ids) { 31 | yield '/foo' => new Message($ids[0]); 32 | yield '/foo' => new Message($ids[1]); 33 | yield '/baz' => new Message($ids[2]); 34 | yield '/bat' => new Message($ids[3]); 35 | }; 36 | 37 | $flatten = function (iterable $messages): array { 38 | $values = []; 39 | foreach ($messages as $message) { 40 | $values[] = $message; 41 | } 42 | 43 | return $values; 44 | }; 45 | 46 | foreach ($messages() as $topic => $message) { 47 | await($storage->storeMessage($topic, $message), $loop); 48 | } 49 | 50 | $subscribedTopics = ['*']; 51 | $bucket = await($storage->retrieveMessagesAfterId($ids[0], $subscribedTopics), Factory::create()); 52 | $received = []; 53 | foreach ($bucket as $topic => $message) { 54 | $received[] = $message; 55 | } 56 | 57 | assertEquals($received, \array_slice($flatten($messages()), 1, 3)); 58 | 59 | $subscribedTopics = ['/foo']; 60 | $bucket = await($storage->retrieveMessagesAfterId($ids[0], $subscribedTopics), Factory::create()); 61 | $received = []; 62 | foreach ($bucket as $topic => $message) { 63 | $received[] = $message; 64 | } 65 | 66 | assertEquals($received, \array_slice($flatten($messages()), 1, 1)); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/Unit/Transport/PHP/PHPTransportFactoryTest.php: -------------------------------------------------------------------------------- 1 | supports($dsn)); 17 | })->with(function () { 18 | yield ['php://localhost', true]; 19 | yield ['http://localhost', false]; 20 | yield ['localhost', false]; 21 | yield ['php:localhost', false]; 22 | }); 23 | 24 | it('creates a transport instance', function () { 25 | $factory = new PHPTransportFactory(); 26 | $loop = Factory::create(); 27 | $promise = $factory->create('php://localhost'); 28 | $transport = await($promise, $loop); 29 | assertInstanceOf(TransportInterface::class, $transport); 30 | assertInstanceOf(PHPTransport::class, $transport); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/Unit/Transport/PHP/PHPTransportTest.php: -------------------------------------------------------------------------------- 1 | subscribe('/foo', $onMessage), $loop); 22 | await($transport->subscribe('/bar/{id}', $onMessage), $loop); 23 | await($transport->publish('/foo', new Message('bar')), $loop); 24 | await($transport->publish('/foo/bar', new Message('baz')), $loop); 25 | await($transport->publish('/bar/baz', new Message('bat')), $loop); 26 | 27 | $expected = [ 28 | '/foo' => [new Message('bar')], 29 | '/bar/baz' => [new Message('bat')], 30 | ]; 31 | 32 | assertEquals($expected, $messages); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/Unit/Transport/Redis/RedisTransportFactoryTest.php: -------------------------------------------------------------------------------- 1 | supports($dsn)); 19 | })->with(function () { 20 | yield ['redis://localhost', true]; 21 | yield ['rediss://localhost', true]; 22 | yield ['http://localhost', false]; 23 | yield ['localhost', false]; 24 | yield ['redis:localhost', false]; 25 | }); 26 | 27 | it('creates an async transport instance', function () { 28 | $loop = Factory::create(); 29 | $factory = new RedisTransportFactory($loop, new NullLogger()); 30 | $promise = $factory->create('redis://localhost'); 31 | $transport = await($promise, $loop); 32 | assertInstanceOf(TransportInterface::class, $transport); 33 | assertInstanceOf(RedisTransport::class, $transport); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/Unit/Transport/Redis/RedisTransportTest.php: -------------------------------------------------------------------------------- 1 | createClient($_SERVER['REDIS_DSN']), $loop); 18 | $publisherClient = await((new Redis\Factory($loop))->createClient($_SERVER['REDIS_DSN']), $loop); 19 | $transport = new RedisTransport($subscriberClient, $publisherClient); 20 | $messages = []; 21 | $onMessage = function (string $topic, Message $message) use (&$messages) { 22 | $messages[$topic][] = $message; 23 | }; 24 | 25 | $subscriptions = [ 26 | $transport->subscribe('/foo', $onMessage), 27 | $transport->subscribe('/bar/{id}', $onMessage), 28 | ]; 29 | 30 | $promises = all($subscriptions)->then(function () use ($transport) { 31 | $publications = [ 32 | $transport->publish('/foo', new Message('bar')), 33 | $transport->publish('/foo/bar', new Message('baz')), 34 | $transport->publish('/bar/baz', new Message('bat')), 35 | ]; 36 | 37 | return all($publications); 38 | }); 39 | 40 | await($promises, $loop); 41 | 42 | $expected = [ 43 | '/foo' => [new Message('bar')], 44 | '/bar/baz' => [new Message('bat')], 45 | ]; 46 | 47 | assertEquals($expected, $messages); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(\dirname(__DIR__) . '/.env'); 9 | --------------------------------------------------------------------------------