├── .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 | 
2 | [](https://codecov.io/gh/bpolaszek/mercure-php-hub)
3 | [](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 |
--------------------------------------------------------------------------------