├── .gitattributes
├── CHANGELOG.md
├── LICENSE
├── README.md
├── composer.json
├── psalm.xml
└── src
├── ConnectionSettings.php
├── ConnectionString.php
├── EventStoreConnectionFactory.php
├── Exception
├── PersistentSubscriptionCommandFailed.php
├── ProjectionCommandConflict.php
├── ProjectionCommandFailed.php
├── UserCommandConflictException.php
└── UserCommandFailed.php
├── Http
└── HttpClient.php
├── Internal
├── ConnectToPersistentSubscriptionOperation.php
├── EventStoreAllCatchUpSubscription.php
├── EventStoreCatchUpSubscription.php
├── EventStoreHttpConnection.php
├── EventStorePersistentSubscription.php
├── EventStoreStreamCatchUpSubscription.php
├── PersistentEventStoreSubscription.php
├── ResolvedEventParser.php
├── VolatileEventStoreAllSubscription.php
└── VolatileEventStoreStreamSubscription.php
├── PersistentSubscriptions
├── PersistentSubscriptionsManager.php
└── PersistentSubscriptionsManagerFactory.php
├── Projections
├── ProjectionsManager.php
├── ProjectionsManagerFactory.php
├── QueryManager.php
└── QueryManagerFactory.php
├── Uri.php
└── UserManagement
├── UsersManager.php
└── UsersManagerFactory.php
/.gitattributes:
--------------------------------------------------------------------------------
1 | /docker export-ignore
2 | /docs export-ignore
3 | /examples export-ignore
4 | /tests export-ignore
5 | .coveralls.yml export-ignore
6 | .docheader export-ignore
7 | .gitignore export-ignore
8 | .php_cs export-ignore
9 | .travis.yml export-ignore
10 | phpunit.xml.dist export-ignore
11 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [v1.0.0-BETA-3](https://github.com/prooph/event-store-http-client/tree/v1.0.0-BETA-3)
4 |
5 | [Full Changelog](https://github.com/prooph/event-store-http-client/compare/v1.0.0-BETA-2...v1.0.0-BETA-3)
6 |
7 | **Fixed bugs:**
8 |
9 | - ResolvedEvent inconsistency [\#32](https://github.com/prooph/event-store-http-client/issues/32)
10 | - Fix undefined index [\#34](https://github.com/prooph/event-store-http-client/pull/34) ([enumag](https://github.com/enumag))
11 |
12 | **Closed issues:**
13 |
14 | - Event data body with escaped quotes [\#35](https://github.com/prooph/event-store-http-client/issues/35)
15 | - Notice: Undefined index: lastCheckpoint [\#31](https://github.com/prooph/event-store-http-client/issues/31)
16 |
17 | ## [v1.0.0-BETA-2](https://github.com/prooph/event-store-http-client/tree/v1.0.0-BETA-2) (2019-02-04)
18 | [Full Changelog](https://github.com/prooph/event-store-http-client/compare/v1.0.0-BETA-1...v1.0.0-BETA-2)
19 |
20 | **Implemented enhancements:**
21 |
22 | - update for event-store v8.0 beta2 [\#30](https://github.com/prooph/event-store-http-client/pull/30) ([prolic](https://github.com/prolic))
23 |
24 | **Merged pull requests:**
25 |
26 | - Fix required version [\#29](https://github.com/prooph/event-store-http-client/pull/29) ([enumag](https://github.com/enumag))
27 |
28 | ## [v1.0.0-BETA-1](https://github.com/prooph/event-store-http-client/tree/v1.0.0-BETA-1) (2019-01-26)
29 | **Implemented enhancements:**
30 |
31 | - Schema inconsistency [\#4](https://github.com/prooph/event-store-http-client/issues/4)
32 | - refactor factories [\#28](https://github.com/prooph/event-store-http-client/pull/28) ([prolic](https://github.com/prolic))
33 | - Tests + fix meta write [\#26](https://github.com/prooph/event-store-http-client/pull/26) ([prolic](https://github.com/prolic))
34 | - Tests [\#25](https://github.com/prooph/event-store-http-client/pull/25) ([prolic](https://github.com/prolic))
35 | - Tests [\#24](https://github.com/prooph/event-store-http-client/pull/24) ([prolic](https://github.com/prolic))
36 | - Tests [\#23](https://github.com/prooph/event-store-http-client/pull/23) ([prolic](https://github.com/prolic))
37 | - Tests [\#22](https://github.com/prooph/event-store-http-client/pull/22) ([prolic](https://github.com/prolic))
38 | - Tests [\#21](https://github.com/prooph/event-store-http-client/pull/21) ([prolic](https://github.com/prolic))
39 | - Tests [\#20](https://github.com/prooph/event-store-http-client/pull/20) ([prolic](https://github.com/prolic))
40 | - bugfixes, add examples [\#19](https://github.com/prooph/event-store-http-client/pull/19) ([prolic](https://github.com/prolic))
41 | - implement Persistent Subscriptions [\#17](https://github.com/prooph/event-store-http-client/pull/17) ([prolic](https://github.com/prolic))
42 | - rename almost all exceptions [\#12](https://github.com/prooph/event-store-http-client/pull/12) ([prolic](https://github.com/prolic))
43 | - implement catch up subscriptions [\#11](https://github.com/prooph/event-store-http-client/pull/11) ([prolic](https://github.com/prolic))
44 | - implement VolatileEventStoreSubscription [\#10](https://github.com/prooph/event-store-http-client/pull/10) ([prolic](https://github.com/prolic))
45 | - use prooph/event-store v8 as core [\#9](https://github.com/prooph/event-store-http-client/pull/9) ([prolic](https://github.com/prolic))
46 | - implement reading $all stream [\#3](https://github.com/prooph/event-store-http-client/pull/3) ([prolic](https://github.com/prolic))
47 | - add factories [\#2](https://github.com/prooph/event-store-http-client/pull/2) ([prolic](https://github.com/prolic))
48 | - implement PersistentSubscriptionsManager [\#1](https://github.com/prooph/event-store-http-client/pull/1) ([prolic](https://github.com/prolic))
49 |
50 | **Fixed bugs:**
51 |
52 | - Bug in deletePersistentSubscription [\#15](https://github.com/prooph/event-store-http-client/issues/15)
53 | - Event resolving logic is wrong [\#14](https://github.com/prooph/event-store-http-client/issues/14)
54 | - Missing classes [\#7](https://github.com/prooph/event-store-http-client/issues/7)
55 | - Fix lastEventNumber on failure [\#27](https://github.com/prooph/event-store-http-client/pull/27) ([enumag](https://github.com/enumag))
56 | - Tests + fix meta write [\#26](https://github.com/prooph/event-store-http-client/pull/26) ([prolic](https://github.com/prolic))
57 | - bugfixes, add examples [\#19](https://github.com/prooph/event-store-http-client/pull/19) ([prolic](https://github.com/prolic))
58 | - implement Persistent Subscriptions [\#17](https://github.com/prooph/event-store-http-client/pull/17) ([prolic](https://github.com/prolic))
59 | - Fix schema and AccessDeniedException [\#6](https://github.com/prooph/event-store-http-client/pull/6) ([enumag](https://github.com/enumag))
60 |
61 | **Closed issues:**
62 |
63 | - Undefined index: eventNumber [\#16](https://github.com/prooph/event-store-http-client/issues/16)
64 |
65 | **Merged pull requests:**
66 |
67 | - Fix metadata key [\#18](https://github.com/prooph/event-store-http-client/pull/18) ([enumag](https://github.com/enumag))
68 | - Fix constructor [\#13](https://github.com/prooph/event-store-http-client/pull/13) ([enumag](https://github.com/enumag))
69 | - Remove unused import [\#8](https://github.com/prooph/event-store-http-client/pull/8) ([enumag](https://github.com/enumag))
70 |
71 |
72 |
73 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*
74 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018-2019, Alexander Miertsch
2 | Copyright (c) 2018-2019, Sascha-Oliver Prolic
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | * Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | * Neither the name of prooph nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Prooph Event Store HTTP Client
2 |
3 | PHP 7.2 Event Store HTTP Client Implementation.
4 |
5 | [](https://travis-ci.org/prooph/event-store-http-client)
6 | [](https://coveralls.io/github/prooph/event-store-http-client?branch=master)
7 | [](https://gitter.im/prooph/improoph)
8 |
9 | ## Overview
10 |
11 | Prooph Event Store HTTP Client supports communication via HTTP to [EventStore](https://eventstore.org/).
12 |
13 | For usage in async event loop, use the [TCP Connection](https://github.com/prooph/event-store-client) instead.
14 |
15 | ## Installation
16 |
17 | ### Client
18 |
19 | You can install prooph/event-store-http-client via composer by adding `"prooph/event-store-http-client": "dev-master"` as requirement to your composer.json.
20 |
21 | ### Server
22 |
23 | Using docker:
24 |
25 | ```bash
26 | docker run --name eventstore-node -it -p 2113:2113 -p 1113:1113 eventstore/eventstore
27 | ```
28 |
29 | Please refer to the documentation of [eventstore.org](https://eventstore.org).
30 |
31 | See [server section](https://eventstore.org/docs/server/index.html).
32 |
33 | In the docker-folder you'll find three different docker-compose setups (single node, 3-node-cluster and 3-node-dns-cluster).
34 |
35 | ## Unit tests
36 |
37 | Run the server with memory database
38 |
39 | ```console
40 | ./run-node.sh --run-projections=all --mem-db
41 | ```
42 |
43 | ```console
44 | ./vendor/bin/phpunit
45 | ```
46 |
47 | Those are tests that only work against an empty database and can only be run manually.
48 |
49 | Before next run, restart the server. This way you can always start with a clean server.
50 |
51 | ## Documentation
52 |
53 | Documentation is on the [prooph website](http://docs.getprooph.org/).
54 |
55 | ## Support
56 |
57 | - Ask questions on Stack Overflow tagged with [#prooph](https://stackoverflow.com/questions/tagged/prooph).
58 | - File issues at [https://github.com/prooph/event-store-http-client/issues](https://github.com/prooph/event-store-http-client/issues).
59 | - Say hello in the [prooph gitter](https://gitter.im/prooph/improoph) chat.
60 |
61 | ## Contribute
62 |
63 | Please feel free to fork and extend existing or add new plugins and send a pull request with your changes!
64 | To establish a consistent code quality, please provide unit tests for all your changes and may adapt the documentation.
65 |
66 | ## License
67 |
68 | Released under the [New BSD License](LICENSE).
69 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prooph/event-store-http-client",
3 | "type": "library",
4 | "license": "BSD-3-Clause",
5 | "authors": [
6 | {
7 | "name": "Sascha-Oliver Prolic",
8 | "email": "saschaprolic@googlemail.com"
9 | }
10 | ],
11 | "description": "Event Store HTTP Client",
12 | "keywords": [
13 | "EventStore",
14 | "EventSourcing",
15 | "DDD",
16 | "prooph"
17 | ],
18 | "prefer-stable": true,
19 | "require": {
20 | "php": "^7.4",
21 | "ext-json": "*",
22 | "php-http/discovery": "^1.9.1",
23 | "php-http/httplug": "^2.2",
24 | "php-http/message": "^1.8.0",
25 | "php-http/message-factory": "^1.0.2",
26 | "prooph/event-store": "dev-master"
27 | },
28 | "require-dev": {
29 | "phpspec/prophecy": "^1.11.1",
30 | "phpunit/phpunit": "^9.1",
31 | "doctrine/instantiator": "^1.3.1",
32 | "guzzlehttp/guzzle": "^6.5.5",
33 | "php-coveralls/php-coveralls": "^2.2",
34 | "php-http/guzzle6-adapter": "^2.0.1",
35 | "prooph/php-cs-fixer-config": "^0.3.1",
36 | "vimeo/psalm": "^3.12.2"
37 | },
38 | "autoload": {
39 | "psr-4": {
40 | "Prooph\\EventStoreHttpClient\\": "src/"
41 | }
42 | },
43 | "autoload-dev": {
44 | "psr-4": {
45 | "ProophTest\\EventStoreHttpClient\\": "tests/"
46 | }
47 | },
48 | "scripts": {
49 | "check": [
50 | "@cs",
51 | "@test"
52 | ],
53 | "cs": "php-cs-fixer fix -v --diff --dry-run",
54 | "cs-fix": "php-cs-fixer fix -v --diff",
55 | "test": "phpunit"
56 | },
57 | "config": {
58 | "sort-packages": true
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/ConnectionSettings.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient;
15 |
16 | use Prooph\EventStore\EndPoint;
17 | use Prooph\EventStore\Transport\Http\EndpointExtensions;
18 | use Prooph\EventStore\UserCredentials;
19 | use Psr\Log\LoggerInterface as Logger;
20 | use Psr\Log\NullLogger;
21 |
22 | class ConnectionSettings
23 | {
24 | private Logger $log;
25 | private bool $verboseLogging;
26 | private EndPoint $endPoint;
27 | private string $schema;
28 | private ?UserCredentials $defaultUserCredentials;
29 | private bool $requireMaster;
30 |
31 | public static function default(): ConnectionSettings
32 | {
33 | return new self(
34 | new NullLogger(),
35 | false,
36 | new EndPoint('localhost', 2113),
37 | EndpointExtensions::HTTP_SCHEMA,
38 | null,
39 | true
40 | );
41 | }
42 |
43 | public function __construct(
44 | Logger $logger,
45 | bool $verboseLogging,
46 | EndPoint $endpoint,
47 | string $schema = EndpointExtensions::HTTP_SCHEMA,
48 | ?UserCredentials $defaultUserCredentials = null,
49 | bool $requireMaster = true
50 | ) {
51 | $this->log = $logger;
52 | $this->verboseLogging = $verboseLogging;
53 | $this->endPoint = $endpoint;
54 | $this->schema = $schema;
55 | $this->defaultUserCredentials = $defaultUserCredentials;
56 | $this->requireMaster = $requireMaster;
57 | }
58 |
59 | /** @psalm-pure */
60 | public function defaultUserCredentials(): ?UserCredentials
61 | {
62 | return $this->defaultUserCredentials;
63 | }
64 |
65 | /** @psalm-pure */
66 | public function schema(): string
67 | {
68 | return $this->schema;
69 | }
70 |
71 | /** @psalm-pure */
72 | public function endPoint(): EndPoint
73 | {
74 | return $this->endPoint;
75 | }
76 |
77 | /** @psalm-pure */
78 | public function requireMaster(): bool
79 | {
80 | return $this->requireMaster;
81 | }
82 |
83 | /** @psalm-pure */
84 | public function log(): Logger
85 | {
86 | return $this->log;
87 | }
88 |
89 | /** @psalm-pure */
90 | public function verboseLogging(): bool
91 | {
92 | return $this->verboseLogging;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/ConnectionString.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient;
15 |
16 | use Prooph\EventStore\EndPoint;
17 | use Prooph\EventStore\Exception\InvalidArgumentException;
18 | use Prooph\EventStore\UserCredentials;
19 | use Psr\Log\NullLogger;
20 |
21 | class ConnectionString
22 | {
23 | private static $allowedValues = [
24 | 'endpoint' => EndPoint::class,
25 | 'schema' => 'string',
26 | 'defaultusercredentials' => UserCredentials::class,
27 | 'requiremaster' => 'bool',
28 | 'verboselogging' => 'bool',
29 | ];
30 |
31 | public static function getConnectionSettings(
32 | string $connectionString,
33 | ?ConnectionSettings $settings = null
34 | ): ConnectionSettings {
35 | $settings = [
36 | 'verboselogging' => false,
37 | 'endpoint' => new EndPoint('localhost', 2113),
38 | 'schema' => 'http',
39 | 'defaultusercredentials' => null,
40 | 'requiremaster' => true,
41 | ];
42 |
43 | foreach (self::getParts($connectionString) as $value) {
44 | [$key, $value] = \explode('=', $value);
45 | $key = \strtolower($key);
46 |
47 | if (! \array_key_exists($key, self::$allowedValues)) {
48 | throw new InvalidArgumentException(\sprintf(
49 | 'Key %s is not an allowed key in %s',
50 | $key,
51 | __CLASS__
52 | ));
53 | }
54 |
55 | $type = self::$allowedValues[$key];
56 |
57 | switch ($type) {
58 | case 'bool':
59 | $filteredValue = \filter_var($value, \FILTER_VALIDATE_BOOLEAN);
60 | break;
61 | case 'int':
62 | $filteredValue = \filter_var($value, \FILTER_VALIDATE_INT);
63 |
64 | if (false === $filteredValue) {
65 | throw new InvalidArgumentException(\sprintf(
66 | 'Expected type for key %s is %s, but %s given',
67 | $key,
68 | $type,
69 | $value
70 | ));
71 | }
72 | break;
73 | case 'string':
74 | $filteredValue = $value;
75 | break;
76 | case EndPoint::class:
77 | $exploded = \explode(':', $value);
78 |
79 | if (\count($exploded) !== 2) {
80 | throw new InvalidArgumentException(\sprintf(
81 | 'Expected user credentials in format user:pass, %s given',
82 | $value
83 | ));
84 | }
85 |
86 | $filteredValue = new EndPoint($exploded[0], (int) $exploded[1]);
87 | break;
88 | case UserCredentials::class:
89 | $exploded = \explode(':', $value);
90 |
91 | if (\count($exploded) !== 2) {
92 | throw new InvalidArgumentException(\sprintf(
93 | 'Expected user credentials in format user:pass, %s given',
94 | $value
95 | ));
96 | }
97 |
98 | $filteredValue = new UserCredentials($exploded[0], $exploded[1]);
99 | break;
100 | }
101 |
102 | $settings[$key] = $filteredValue;
103 | }
104 |
105 | return new ConnectionSettings(
106 | new NullLogger(),
107 | $settings['verboselogging'],
108 | $settings['endpoint'],
109 | $settings['schema'],
110 | $settings['defaultusercredentials'],
111 | $settings['requiremaster']
112 | );
113 | }
114 |
115 | /**
116 | * @param string $connectionString
117 | *
118 | * @return string[]
119 | */
120 | private static function getParts(string $connectionString): array
121 | {
122 | return \explode(';', \str_replace(' ', '', $connectionString));
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/EventStoreConnectionFactory.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient;
15 |
16 | use Http\Discovery\HttpClientDiscovery;
17 | use Http\Discovery\MessageFactoryDiscovery;
18 | use Http\Message\RequestFactory;
19 | use Prooph\EventStore\EventStoreConnection;
20 | use Prooph\EventStoreHttpClient\Internal\EventStoreHttpConnection;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | class EventStoreConnectionFactory
24 | {
25 | public static function create(
26 | ConnectionSettings $settings = null,
27 | ClientInterface $client = null,
28 | RequestFactory $requestFactory = null
29 | ): EventStoreConnection {
30 | return new EventStoreHttpConnection(
31 | $settings ?? ConnectionSettings::default(),
32 | $client ?? HttpClientDiscovery::find(),
33 | $requestFactory ?? MessageFactoryDiscovery::find()
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Exception/PersistentSubscriptionCommandFailed.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Exception;
15 |
16 | use Prooph\EventStore\Exception\PersistentSubscriptionCommandFailed as BaseException;
17 |
18 | class PersistentSubscriptionCommandFailed extends BaseException
19 | {
20 | private int $httpStatusCode;
21 |
22 | public function __construct(int $httpStatusCode, string $message)
23 | {
24 | $this->httpStatusCode = $httpStatusCode;
25 |
26 | parent::__construct($message);
27 | }
28 |
29 | public function httpStatusCode(): int
30 | {
31 | return $this->httpStatusCode;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Exception/ProjectionCommandConflict.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Exception;
15 |
16 | use Prooph\EventStore\Exception\ProjectionCommandConflict as BaseException;
17 |
18 | class ProjectionCommandConflict extends BaseException
19 | {
20 | private int $httpStatusCode;
21 |
22 | public function __construct(int $httpStatusCode, string $message)
23 | {
24 | $this->httpStatusCode = $httpStatusCode;
25 |
26 | parent::__construct($message);
27 | }
28 |
29 | public function httpStatusCode(): int
30 | {
31 | return $this->httpStatusCode;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Exception/ProjectionCommandFailed.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Exception;
15 |
16 | use Prooph\EventStore\Exception\ProjectionCommandFailed as BaseException;
17 |
18 | class ProjectionCommandFailed extends BaseException
19 | {
20 | private int $httpStatusCode;
21 |
22 | public function __construct(int $httpStatusCode, string $message)
23 | {
24 | $this->httpStatusCode = $httpStatusCode;
25 |
26 | parent::__construct($message);
27 | }
28 |
29 | public function httpStatusCode(): int
30 | {
31 | return $this->httpStatusCode;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Exception/UserCommandConflictException.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Exception;
15 |
16 | use Prooph\EventStore\Exception\UserCommandConflict as BaseException;
17 |
18 | class UserCommandConflictException extends BaseException
19 | {
20 | private int $httpStatusCode;
21 |
22 | public function __construct(int $httpStatusCode, string $message)
23 | {
24 | $this->httpStatusCode = $httpStatusCode;
25 |
26 | parent::__construct($message);
27 | }
28 |
29 | public function httpStatusCode(): int
30 | {
31 | return $this->httpStatusCode;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Exception/UserCommandFailed.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Exception;
15 |
16 | use Prooph\EventStore\Exception\UserCommandFailed as BaseException;
17 |
18 | class UserCommandFailed extends BaseException
19 | {
20 | private int $httpStatusCode;
21 |
22 | public function __construct(int $httpStatusCode, string $message)
23 | {
24 | $this->httpStatusCode = $httpStatusCode;
25 |
26 | parent::__construct($message);
27 | }
28 |
29 | public function httpStatusCode(): int
30 | {
31 | return $this->httpStatusCode;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Http/HttpClient.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Http;
15 |
16 | use Http\Message\RequestFactory;
17 | use Prooph\EventStore\Transport\Http\HttpMethod;
18 | use Prooph\EventStore\UserCredentials;
19 | use Prooph\EventStoreHttpClient\ConnectionSettings;
20 | use Psr\Http\Client\ClientInterface;
21 | use Psr\Http\Message\RequestInterface;
22 | use Psr\Http\Message\ResponseInterface;
23 | use Throwable;
24 |
25 | /** @internal */
26 | class HttpClient
27 | {
28 | private ClientInterface $client;
29 | private RequestFactory $requestFactory;
30 | private ConnectionSettings $settings;
31 | private string $baseUri;
32 |
33 | public function __construct(
34 | ClientInterface $client,
35 | RequestFactory $requestFactory,
36 | ConnectionSettings $settings,
37 | string $baseUri
38 | ) {
39 | $this->client = $client;
40 | $this->requestFactory = $requestFactory;
41 | $this->settings = $settings;
42 | $this->baseUri = $baseUri;
43 | }
44 |
45 | public function get(
46 | string $uri,
47 | array $headers,
48 | ?UserCredentials $userCredentials,
49 | callable $onException
50 | ): ResponseInterface {
51 | return $this->receive(
52 | HttpMethod::GET,
53 | $this->baseUri . $uri,
54 | $headers,
55 | $userCredentials ?? $this->settings->defaultUserCredentials(),
56 | $onException
57 | );
58 | }
59 |
60 | public function post(
61 | string $uri,
62 | array $headers,
63 | string $body,
64 | ?UserCredentials $userCredentials,
65 | callable $onException
66 | ): ResponseInterface {
67 | return $this->send(
68 | HttpMethod::POST,
69 | $this->baseUri . $uri,
70 | $headers,
71 | $body,
72 | $userCredentials ?? $this->settings->defaultUserCredentials(),
73 | $onException
74 | );
75 | }
76 |
77 | public function delete(
78 | string $uri,
79 | array $headers,
80 | ?UserCredentials $userCredentials,
81 | callable $onException
82 | ): ResponseInterface {
83 | return $this->receive(
84 | HttpMethod::DELETE,
85 | $this->baseUri . $uri,
86 | $headers,
87 | $userCredentials ?? $this->settings->defaultUserCredentials(),
88 | $onException
89 | );
90 | }
91 |
92 | public function put(
93 | string $url,
94 | array $headers,
95 | string $body,
96 | ?UserCredentials $userCredentials,
97 | callable $onException
98 | ): ResponseInterface {
99 | return $this->send(
100 | HttpMethod::PUT,
101 | $this->baseUri . $url,
102 | $headers,
103 | $body,
104 | $userCredentials ?? $this->settings->defaultUserCredentials(),
105 | $onException
106 | );
107 | }
108 |
109 | private function receive(
110 | string $method,
111 | string $uri,
112 | array $headers,
113 | ?UserCredentials $userCredentials,
114 | callable $onException
115 | ): ResponseInterface {
116 | $request = $this->requestFactory->createRequest($method, $uri, $headers);
117 |
118 | if (null !== $userCredentials) {
119 | $request = $this->addAuthenticationHeader($request, $userCredentials);
120 | }
121 |
122 | try {
123 | return $this->client->sendRequest($request);
124 | } catch (Throwable $e) {
125 | $onException($e);
126 | }
127 | }
128 |
129 | private function send(
130 | string $method,
131 | string $uri,
132 | array $headers,
133 | string $body,
134 | ?UserCredentials $userCredentials,
135 | callable $onException
136 | ): ResponseInterface {
137 | $request = $this->requestFactory->createRequest($method, $uri, $headers, $body);
138 |
139 | if (null !== $userCredentials) {
140 | $request = $this->addAuthenticationHeader($request, $userCredentials);
141 | }
142 |
143 | $request = $request->withHeader('Content-Length', (string) \strlen($body));
144 |
145 | try {
146 | return $this->client->sendRequest($request);
147 | } catch (Throwable $e) {
148 | $onException($e);
149 | }
150 | }
151 |
152 | private function addAuthenticationHeader(
153 | RequestInterface $request,
154 | UserCredentials $userCredentials
155 | ): RequestInterface {
156 | $httpAuthentication = \sprintf(
157 | '%s:%s',
158 | $userCredentials->username(),
159 | $userCredentials->password()
160 | );
161 |
162 | $encodedCredentials = \base64_encode($httpAuthentication);
163 |
164 | return $request->withHeader('Authorization', 'Basic ' . $encodedCredentials);
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/src/Internal/ConnectToPersistentSubscriptionOperation.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\EventId;
18 | use Prooph\EventStore\EventStoreSubscription;
19 | use Prooph\EventStore\Exception\AccessDenied;
20 | use Prooph\EventStore\Exception\EventStoreConnectionException;
21 | use Prooph\EventStore\Exception\InvalidArgumentException;
22 | use Prooph\EventStore\Exception\RuntimeException;
23 | use Prooph\EventStore\Internal\ConnectToPersistentSubscriptions;
24 | use Prooph\EventStore\Internal\PersistentEventStoreSubscription;
25 | use Prooph\EventStore\PersistentSubscriptionNakEventAction;
26 | use Prooph\EventStore\PersistentSubscriptionResolvedEvent;
27 | use Prooph\EventStore\SubscriptionDropReason;
28 | use Prooph\EventStore\UserCredentials;
29 | use Prooph\EventStoreHttpClient\Http\HttpClient;
30 | use SplQueue;
31 | use Throwable;
32 |
33 | /** @internal */
34 | class ConnectToPersistentSubscriptionOperation implements ConnectToPersistentSubscriptions
35 | {
36 | private HttpClient $httpClient;
37 | private string $groupName;
38 | private int $bufferSize;
39 | private string $subscriptionId;
40 | protected string $streamId;
41 | protected bool $resolveLinkTos;
42 | protected ?UserCredentials $userCredentials;
43 | protected Closure $eventAppeared;
44 | private ?Closure $subscriptionDropped = null;
45 | private SplQueue $actionQueue;
46 | private ?EventStoreSubscription $subscription = null;
47 | private bool $unsubscribed = false;
48 |
49 | public function __construct(
50 | HttpClient $httpClient,
51 | string $groupName,
52 | int $bufferSize,
53 | string $streamId,
54 | ?UserCredentials $userCredentials,
55 | callable $eventAppeared,
56 | ?callable $subscriptionDropped
57 | ) {
58 | $this->httpClient = $httpClient;
59 | $this->groupName = $groupName;
60 | $this->bufferSize = $bufferSize;
61 | $this->streamId = $streamId;
62 | $this->resolveLinkTos = false;
63 | $this->userCredentials = $userCredentials;
64 | $this->eventAppeared = $eventAppeared;
65 | $this->subscriptionDropped = $subscriptionDropped;
66 | $this->actionQueue = new SplQueue();
67 | }
68 |
69 | public function readFromSubscription(int $amount): array
70 | {
71 | $response = $this->httpClient->get(
72 | \sprintf(
73 | '/subscriptions/%s/%s/%d?embed=tryharder',
74 | \urlencode($this->streamId),
75 | \urlencode($this->groupName),
76 | $amount
77 | ),
78 | [
79 | 'Accept' => 'application/vnd.eventstore.competingatom+json',
80 | ],
81 | $this->userCredentials,
82 | static function (Throwable $e) {
83 | throw new EventStoreConnectionException($e->getMessage());
84 | }
85 | );
86 |
87 | switch ($response->getStatusCode()) {
88 | case 401:
89 | throw AccessDenied::toStream($this->streamId);
90 | case 404:
91 | throw new RuntimeException(\sprintf(
92 | 'Subscription with stream \'%s\' and group name \'%s\' not found',
93 | $this->streamId,
94 | $this->groupName
95 | ));
96 | case 200:
97 | $json = \json_decode($response->getBody()->getContents(), true);
98 |
99 | $events = [];
100 |
101 | if (null === $json) {
102 | return $events;
103 | }
104 |
105 | if (empty($json['entries'])) {
106 | \sleep(1); // @todo make configurable?
107 |
108 | return $events;
109 | }
110 |
111 | foreach (\array_reverse($json['entries']) as $entry) {
112 | $events[] = new PersistentSubscriptionResolvedEvent(
113 | ResolvedEventParser::parse($entry),
114 | null
115 | );
116 | }
117 |
118 | return $events;
119 | default:
120 | throw new EventStoreConnectionException(\sprintf(
121 | 'Unexpected status code %d returned',
122 | $response->getStatusCode()
123 | ));
124 | }
125 | }
126 |
127 | public function createSubscriptionObject(): PersistentEventStoreSubscription
128 | {
129 | return new PersistentEventStoreSubscription(
130 | $this,
131 | $this->streamId,
132 | 0,
133 | null
134 | );
135 | }
136 |
137 | /** @param EventId[] $eventIds */
138 | public function notifyEventsProcessed(array $eventIds): void
139 | {
140 | if (empty($eventIds)) {
141 | throw new InvalidArgumentException('EventIds cannot be empty');
142 | }
143 |
144 | $eventIds = \array_map(function (EventId $eventId): string {
145 | return $eventId->toString();
146 | }, $eventIds);
147 |
148 | $response = $this->httpClient->post(
149 | \sprintf(
150 | '/subscriptions/%s/%s/ack?ids=%s',
151 | \urlencode($this->streamId),
152 | \urlencode($this->groupName),
153 | \implode(',', $eventIds)
154 | ),
155 | [
156 | 'Content-Length' => 0,
157 | ],
158 | '',
159 | $this->userCredentials,
160 | static function (Throwable $e) {
161 | throw new EventStoreConnectionException($e->getMessage());
162 | }
163 | );
164 |
165 | switch ($response->getStatusCode()) {
166 | case 202:
167 | return;
168 | case 401:
169 | throw AccessDenied::toStream($this->streamId);
170 | default:
171 | throw new EventStoreConnectionException(\sprintf(
172 | 'Unexpected status code %d returned',
173 | $response->getStatusCode()
174 | ));
175 | }
176 | }
177 |
178 | /**
179 | * @param EventId[] $eventIds
180 | * @param PersistentSubscriptionNakEventAction $action
181 | * @param string $reason
182 | */
183 | public function notifyEventsFailed(
184 | array $eventIds,
185 | PersistentSubscriptionNakEventAction $action,
186 | string $reason
187 | ): void {
188 | if (empty($eventIds)) {
189 | throw new InvalidArgumentException('EventIds cannot be empty');
190 | }
191 |
192 | $eventIds = \array_map(function (EventId $eventId): string {
193 | return $eventId->toString();
194 | }, $eventIds);
195 |
196 | $response = $this->httpClient->post(
197 | \sprintf(
198 | '/subscriptions/%s/%s/nack?ids=%s&action=%s',
199 | \urlencode($this->streamId),
200 | \urlencode($this->groupName),
201 | \implode(',', $eventIds),
202 | $action->name()
203 | ),
204 | [
205 | 'Content-Length' => 0,
206 | ],
207 | '',
208 | $this->userCredentials,
209 | static function (Throwable $e) {
210 | throw new EventStoreConnectionException($e->getMessage());
211 | }
212 | );
213 |
214 | switch ($response->getStatusCode()) {
215 | case 202:
216 | return;
217 | case 401:
218 | throw AccessDenied::toStream($this->streamId);
219 | default:
220 | throw new EventStoreConnectionException(\sprintf(
221 | 'Unexpected status code %d returned',
222 | $response->getStatusCode()
223 | ));
224 | }
225 | }
226 |
227 | public function unsubscribe(): void
228 | {
229 | $this->dropSubscription(SubscriptionDropReason::userInitiated(), null);
230 | }
231 |
232 | public function dropSubscription(
233 | SubscriptionDropReason $reason,
234 | ?Throwable $exception = null
235 | ): void {
236 | if (! $this->unsubscribed) {
237 | $this->unsubscribed = true;
238 |
239 | if (! $reason->equals(SubscriptionDropReason::userInitiated())) {
240 | $exception = $exception ?? new RuntimeException('Subscription dropped for ' . $reason);
241 |
242 | throw $exception;
243 | }
244 |
245 | if ($reason->equals(SubscriptionDropReason::userInitiated())
246 | && null !== $this->subscription
247 | ) {
248 | return;
249 | }
250 |
251 | if (null !== $this->subscription
252 | && $this->subscriptionDropped
253 | ) {
254 | ($this->subscriptionDropped)($this->subscription, $reason, $exception);
255 | }
256 | }
257 | }
258 |
259 | public function name(): string
260 | {
261 | return 'ConnectToPersistentSubscription';
262 | }
263 |
264 | public function __toString(): string
265 | {
266 | return \sprintf(
267 | 'StreamId: %s, ResolveLinkTos: %s, GroupName: %s, BufferSize: %d, SubscriptionId: %s',
268 | $this->streamId,
269 | $this->resolveLinkTos ? 'yes' : 'no',
270 | $this->groupName,
271 | $this->bufferSize,
272 | $this->subscriptionId
273 | );
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/src/Internal/EventStoreAllCatchUpSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\AllEventsSlice;
18 | use Prooph\EventStore\CatchUpSubscriptionSettings;
19 | use Prooph\EventStore\EventStoreAllCatchUpSubscription as EventStoreAllCatchUpSubscriptionInterface;
20 | use Prooph\EventStore\EventStoreConnection;
21 | use Prooph\EventStore\Exception\RuntimeException;
22 | use Prooph\EventStore\Position;
23 | use Prooph\EventStore\ResolvedEvent;
24 | use Prooph\EventStore\SubscriptionDropReason;
25 | use Prooph\EventStore\UserCredentials;
26 | use Psr\Log\LoggerInterface;
27 | use Throwable;
28 |
29 | class EventStoreAllCatchUpSubscription extends EventStoreCatchUpSubscription implements EventStoreAllCatchUpSubscriptionInterface
30 | {
31 | private Position $nextReadPosition;
32 | private Position $lastProcessedPosition;
33 |
34 | /**
35 | * @internal
36 | *
37 | * @param Closure(EventStoreCatchUpSubscription, ResolvedEvent): void $eventAppeared
38 | * @param null|Closure(EventStoreCatchUpSubscription): void $liveProcessingStarted
39 | * @param null|Closure(EventStoreCatchUpSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
40 | */
41 | public function __construct(
42 | EventStoreConnection $connection,
43 | LoggerInterface $logger,
44 | ?Position $fromPositionExclusive, // if null from the very beginning
45 | ?UserCredentials $userCredentials,
46 | Closure $eventAppeared,
47 | ?Closure $liveProcessingStarted,
48 | ?Closure $subscriptionDropped,
49 | CatchUpSubscriptionSettings $settings
50 | ) {
51 | parent::__construct(
52 | $connection,
53 | $logger,
54 | '',
55 | $userCredentials,
56 | $eventAppeared,
57 | $liveProcessingStarted,
58 | $subscriptionDropped,
59 | $settings
60 | );
61 |
62 | $this->lastProcessedPosition = $fromPositionExclusive ?? Position::end();
63 | $this->nextReadPosition = $fromPositionExclusive ?? Position::start();
64 | }
65 |
66 | public function lastProcessedPosition(): Position
67 | {
68 | return $this->lastProcessedPosition;
69 | }
70 |
71 | protected function readEventsTill(
72 | EventStoreConnection $connection,
73 | bool $resolveLinkTos,
74 | ?UserCredentials $userCredentials,
75 | ?int $lastCommitPosition,
76 | ?int $lastEventNumber
77 | ): void {
78 | $this->readEventsInternal($connection, $resolveLinkTos, $userCredentials, $lastCommitPosition);
79 | }
80 |
81 | private function readEventsInternal(
82 | EventStoreConnection $connection,
83 | bool $resolveLinkTos,
84 | ?UserCredentials $userCredentials,
85 | ?int $lastCommitPosition
86 | ): void {
87 | do {
88 | $slice = $connection->readAllEventsForward(
89 | $this->nextReadPosition,
90 | $this->readBatchSize,
91 | $resolveLinkTos,
92 | $userCredentials
93 | );
94 |
95 | $shouldStopOrDone = $this->readEventsCallback($slice, $lastCommitPosition);
96 | } while (! $shouldStopOrDone);
97 | }
98 |
99 | private function readEventsCallback(AllEventsSlice $slice, ?int $lastCommitPosition): bool
100 | {
101 | $shouldStopOrDone = $this->shouldStop || $this->processEvents($lastCommitPosition, $slice);
102 |
103 | if ($shouldStopOrDone && $this->verbose) {
104 | $this->log->debug(\sprintf(
105 | 'Catch-up Subscription %s to %s: finished reading events, nextReadPosition = %s',
106 | $this->subscriptionName(),
107 | $this->isSubscribedToAll() ? '' : $this->streamId(),
108 | (string) $this->nextReadPosition
109 | ));
110 | }
111 |
112 | return $shouldStopOrDone;
113 | }
114 |
115 | private function processEvents(?int $lastCommitPosition, AllEventsSlice $slice): bool
116 | {
117 | foreach ($slice->events() as $e) {
118 | if (null === $e->originalPosition()) {
119 | throw new RuntimeException(\sprintf(
120 | 'Subscription %s event came up with no OriginalPosition',
121 | $this->subscriptionName()
122 | ));
123 | }
124 |
125 | $this->tryProcess($e);
126 | }
127 |
128 | $this->nextReadPosition = $slice->nextPosition();
129 |
130 | $done = (null === $lastCommitPosition)
131 | ? $slice->isEndOfStream()
132 | : $slice->nextPosition()->greaterOrEquals(new Position($lastCommitPosition, $lastCommitPosition));
133 |
134 | if (! $done && $slice->isEndOfStream()) {
135 | // we are waiting for server to flush its data
136 | \sleep(1);
137 | }
138 |
139 | return $done;
140 | }
141 |
142 | protected function tryProcess(ResolvedEvent $e): void
143 | {
144 | $processed = false;
145 |
146 | if ($e->originalPosition()->greater($this->lastProcessedPosition)) {
147 | try {
148 | ($this->eventAppeared)($this, $e);
149 | } catch (Throwable $ex) {
150 | $this->dropSubscription(SubscriptionDropReason::eventHandlerException(), $ex);
151 | }
152 |
153 | $this->lastProcessedPosition = $e->originalPosition();
154 | $processed = true;
155 | }
156 |
157 | if ($this->verbose) {
158 | /** @psalm-suppress PossiblyNullReference */
159 | $this->log->debug(\sprintf(
160 | 'Catch-up Subscription %s to %s: %s event (%s, %d, %s @ %s)',
161 | $this->subscriptionName(),
162 | $this->isSubscribedToAll() ? '' : $this->streamId(),
163 | $processed ? 'processed' : 'skipping',
164 | $e->originalEvent()->eventStreamId(),
165 | $e->originalEvent()->eventNumber(),
166 | $e->originalEvent()->eventType(),
167 | $e->originalPosition() ? $e->originalPosition()->__toString() : ''
168 | ));
169 | }
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/Internal/EventStoreCatchUpSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\CatchUpSubscriptionSettings;
18 | use Prooph\EventStore\EventStoreCatchUpSubscription as SyncEventStoreCatchUpSubscription;
19 | use Prooph\EventStore\EventStoreConnection;
20 | use Prooph\EventStore\EventStoreSubscription;
21 | use Prooph\EventStore\Internal\DropData;
22 | use Prooph\EventStore\ResolvedEvent;
23 | use Prooph\EventStore\SubscriptionDropReason;
24 | use Prooph\EventStore\UserCredentials;
25 | use Psr\Log\LoggerInterface as Logger;
26 | use SplQueue;
27 | use Throwable;
28 |
29 | abstract class EventStoreCatchUpSubscription implements SyncEventStoreCatchUpSubscription
30 | {
31 | private ?ResolvedEvent $dropSubscriptionEvent = null;
32 |
33 | protected Logger $log;
34 |
35 | private bool $isSubscribedToAll;
36 | private string $streamId;
37 | private string $subscriptionName;
38 |
39 | private EventStoreConnection $connection;
40 | private bool $resolveLinkTos;
41 | private ?UserCredentials $userCredentials;
42 |
43 | protected int $readBatchSize;
44 | protected int $maxPushQueueSize;
45 |
46 | /** @var Closure(EventStoreCatchUpSubscription, ResolvedEvent): void */
47 | protected Closure $eventAppeared;
48 | /** @var null|Closure(EventStoreCatchUpSubscription): void */
49 | private ?Closure $liveProcessingStarted;
50 | /** @var null|Closure(EventStoreCatchUpSubscription, SubscriptionDropReason, null|Throwable) */
51 | private ?Closure $subscriptionDropped;
52 |
53 | /** @var SplQueue */
54 | private SplQueue $liveQueue;
55 | private ?EventStoreSubscription $subscription;
56 | protected bool $verbose;
57 | private ?DropData $dropData;
58 | private bool $allowProcessing;
59 | private bool $isProcessing;
60 | protected bool $shouldStop = false;
61 | private bool $isDropped = false;
62 | private bool $stopped = true;
63 |
64 | /**
65 | * @internal
66 | *
67 | * @param Closure(EventStoreCatchUpSubscription, ResolvedEvent): void $eventAppeared
68 | * @param null|Closure(EventStoreCatchUpSubscription): void $liveProcessingStarted
69 | * @param null|Closure(EventStoreCatchUpSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
70 | */
71 | public function __construct(
72 | EventStoreConnection $connection,
73 | Logger $logger,
74 | string $streamId,
75 | ?UserCredentials $userCredentials,
76 | Closure $eventAppeared,
77 | ?Closure $liveProcessingStarted,
78 | ?Closure $subscriptionDropped,
79 | CatchUpSubscriptionSettings $settings
80 | ) {
81 | if (null === $this->dropSubscriptionEvent) {
82 | $this->dropSubscriptionEvent = new ResolvedEvent(null, null, null);
83 | }
84 |
85 | $this->connection = $connection;
86 | $this->isSubscribedToAll = empty($streamId);
87 | $this->streamId = $streamId;
88 | $this->userCredentials = $userCredentials;
89 | $this->eventAppeared = $eventAppeared;
90 | $this->liveProcessingStarted = $liveProcessingStarted;
91 | $this->subscriptionDropped = $subscriptionDropped;
92 | $this->resolveLinkTos = $settings->resolveLinkTos();
93 | $this->readBatchSize = $settings->readBatchSize();
94 | $this->maxPushQueueSize = $settings->maxLiveQueueSize();
95 | $this->verbose = $settings->verboseLogging();
96 | $this->liveQueue = new SplQueue();
97 | $this->subscriptionName = $settings->subscriptionName() ?? '';
98 | $this->stopped = true;
99 | $this->subscription = null;
100 | }
101 |
102 | public function isSubscribedToAll(): bool
103 | {
104 | return $this->isSubscribedToAll;
105 | }
106 |
107 | public function streamId(): string
108 | {
109 | return $this->streamId;
110 | }
111 |
112 | public function subscriptionName(): string
113 | {
114 | return $this->subscriptionName;
115 | }
116 |
117 | abstract protected function readEventsTill(
118 | EventStoreConnection $connection,
119 | bool $resolveLinkTos,
120 | ?UserCredentials $userCredentials,
121 | ?int $lastCommitPosition,
122 | ?int $lastEventNumber
123 | ): void;
124 |
125 | abstract protected function tryProcess(ResolvedEvent $e): void;
126 |
127 | /**
128 | * @throws Throwable
129 | */
130 | public function start(): void
131 | {
132 | if ($this->verbose) {
133 | $this->log->debug(\sprintf(
134 | 'Catch-up Subscription %s to %s: starting...',
135 | $this->subscriptionName,
136 | $this->isSubscribedToAll ? '' : $this->streamId
137 | ));
138 | }
139 |
140 | $this->runSubscription();
141 | }
142 |
143 | public function stop(): void
144 | {
145 | if ($this->verbose) {
146 | $this->log->debug(\sprintf(
147 | 'Catch-up Subscription %s to %s: requesting stop...',
148 | $this->subscriptionName,
149 | $this->isSubscribedToAll ? '' : $this->streamId
150 | ));
151 | }
152 |
153 | $this->shouldStop = true;
154 | $this->enqueueSubscriptionDropNotification(SubscriptionDropReason::userInitiated(), null);
155 |
156 | if ($this->verbose) {
157 | $this->log->debug(\sprintf(
158 | 'Waiting on subscription %s to stop',
159 | $this->subscriptionName
160 | ));
161 | }
162 | }
163 |
164 | /** @throws Throwable */
165 | private function runSubscription(): void
166 | {
167 | $this->loadHistoricalEvents();
168 | }
169 |
170 | /** @throws Throwable */
171 | private function loadHistoricalEvents(): void
172 | {
173 | if ($this->verbose) {
174 | $this->log->debug(\sprintf(
175 | 'Catch-up Subscription %s to %s: running...',
176 | $this->subscriptionName,
177 | $this->isSubscribedToAll ? '' : $this->streamId
178 | ));
179 | }
180 |
181 | $this->stopped = false;
182 | $this->allowProcessing = false;
183 |
184 | if (! $this->shouldStop) {
185 | if ($this->verbose) {
186 | $this->log->debug(\sprintf(
187 | 'Catch-up Subscription %s to %s: pulling events...',
188 | $this->subscriptionName,
189 | $this->isSubscribedToAll ? '' : $this->streamId
190 | ));
191 | }
192 |
193 | try {
194 | $this->readEventsTill($this->connection, $this->resolveLinkTos, $this->userCredentials, null, null);
195 | $this->subscribeToStream();
196 | } catch (Throwable $ex) {
197 | $this->dropSubscription(SubscriptionDropReason::catchUpError(), $ex);
198 |
199 | throw $ex;
200 | }
201 | } else {
202 | $this->dropSubscription(SubscriptionDropReason::userInitiated(), null);
203 | }
204 | }
205 |
206 | private function subscribeToStream(): void
207 | {
208 | if (! $this->shouldStop) {
209 | if ($this->verbose) {
210 | $this->log->debug(\sprintf(
211 | 'Catch-up Subscription %s to %s: subscribing...',
212 | $this->subscriptionName,
213 | $this->isSubscribedToAll ? '' : $this->streamId
214 | ));
215 | }
216 |
217 | $eventAppeared = function (
218 | EventStoreSubscription $subscription,
219 | ResolvedEvent $resolvedEvent
220 | ): void {
221 | $this->enqueuePushedEvent($subscription, $resolvedEvent);
222 | };
223 |
224 | $subscriptionDropped = function (
225 | EventStoreSubscription $subscription,
226 | SubscriptionDropReason $reason,
227 | ?Throwable $exception = null
228 | ): void {
229 | $this->serverSubscriptionDropped($reason, $exception);
230 | };
231 |
232 | $subscription = empty($this->streamId)
233 | ? $this->connection->subscribeToAll(
234 | $this->resolveLinkTos,
235 | $eventAppeared,
236 | $subscriptionDropped,
237 | $this->userCredentials
238 | )
239 | : $this->connection->subscribeToStream(
240 | $this->streamId,
241 | $this->resolveLinkTos,
242 | $eventAppeared,
243 | $subscriptionDropped,
244 | $this->userCredentials
245 | );
246 |
247 | $this->subscription = $subscription;
248 |
249 | $this->readMissedHistoricEvents();
250 |
251 | $this->subscription->start();
252 | } else {
253 | $this->dropSubscription(SubscriptionDropReason::userInitiated(), null);
254 | }
255 | }
256 |
257 | private function readMissedHistoricEvents(): void
258 | {
259 | if (! $this->shouldStop) {
260 | if ($this->verbose) {
261 | $this->log->debug(\sprintf(
262 | 'Catch-up Subscription %s to %s: pulling events (if left)...',
263 | $this->subscriptionName,
264 | $this->isSubscribedToAll ? '' : $this->streamId
265 | ));
266 | }
267 |
268 | $this->readEventsTill(
269 | $this->connection,
270 | $this->resolveLinkTos,
271 | $this->userCredentials,
272 | $this->subscription->lastCommitPosition(),
273 | $this->subscription->lastEventNumber()
274 | );
275 | $this->startLiveProcessing();
276 | } else {
277 | $this->dropSubscription(SubscriptionDropReason::userInitiated(), null);
278 | }
279 | }
280 |
281 | private function startLiveProcessing(): void
282 | {
283 | if ($this->shouldStop) {
284 | $this->dropSubscription(SubscriptionDropReason::userInitiated(), null);
285 |
286 | return;
287 | }
288 |
289 | if ($this->verbose) {
290 | $this->log->debug(\sprintf(
291 | 'Catch-up Subscription %s to %s: processing live events...',
292 | $this->subscriptionName,
293 | $this->isSubscribedToAll ? '' : $this->streamId
294 | ));
295 | }
296 |
297 | if ($this->liveProcessingStarted) {
298 | ($this->liveProcessingStarted)($this);
299 | }
300 |
301 | $this->allowProcessing = true;
302 |
303 | $this->ensureProcessingPushQueue();
304 | }
305 |
306 | private function enqueuePushedEvent(EventStoreSubscription $subscription, ResolvedEvent $e): void
307 | {
308 | if ($this->verbose) {
309 | /** @psalm-suppress PossiblyNullReference */
310 | $this->log->debug(\sprintf(
311 | 'Catch-up Subscription %s to %s: event appeared (%s, %s, %s, @ %s)',
312 | $this->subscriptionName,
313 | $this->isSubscribedToAll ? '' : $this->streamId,
314 | $e->originalStreamName(),
315 | $e->originalEventNumber(),
316 | $e->originalEvent()->eventType(),
317 | (string) $e->originalPosition()
318 | ));
319 | }
320 |
321 | if ($this->liveQueue->count() >= $this->maxPushQueueSize) {
322 | $this->enqueueSubscriptionDropNotification(SubscriptionDropReason::processingQueueOverflow(), null);
323 | $subscription->unsubscribe();
324 |
325 | return;
326 | }
327 |
328 | $this->liveQueue->enqueue($e);
329 |
330 | if ($this->allowProcessing) {
331 | $this->ensureProcessingPushQueue();
332 | }
333 | }
334 |
335 | private function serverSubscriptionDropped(
336 | SubscriptionDropReason $reason,
337 | ?Throwable $exception): void
338 | {
339 | $this->enqueueSubscriptionDropNotification($reason, $exception);
340 | }
341 |
342 | private function enqueueSubscriptionDropNotification(SubscriptionDropReason $reason, ?Throwable $error): void
343 | {
344 | // if drop data was already set -- no need to enqueue drop again, somebody did that already
345 | $dropData = new DropData($reason, $error);
346 |
347 | if (null === $this->dropData) {
348 | $this->dropData = $dropData;
349 |
350 | $this->liveQueue->enqueue($this->dropSubscriptionEvent);
351 |
352 | if ($this->allowProcessing) {
353 | $this->ensureProcessingPushQueue();
354 | }
355 | }
356 | }
357 |
358 | private function ensureProcessingPushQueue(): void
359 | {
360 | if (! $this->isProcessing) {
361 | $this->isProcessing = true;
362 |
363 | $this->processLiveQueue();
364 | }
365 | }
366 |
367 | private function processLiveQueue(): void
368 | {
369 | do {
370 | while (! $this->liveQueue->isEmpty()) {
371 | $e = $this->liveQueue->dequeue();
372 | \assert($e instanceof ResolvedEvent);
373 |
374 | if ($e === $this->dropSubscriptionEvent) {
375 | $this->dropData = $this->dropData ?? new DropData(SubscriptionDropReason::unknown(), new \Exception('Drop reason not specified'));
376 | $this->dropSubscription($this->dropData->reason(), $this->dropData->error());
377 |
378 | $this->isProcessing = false;
379 |
380 | return;
381 | }
382 |
383 | try {
384 | $this->tryProcess($e);
385 | } catch (Throwable $ex) {
386 | $this->dropSubscription(SubscriptionDropReason::eventHandlerException(), $ex);
387 |
388 | return;
389 | }
390 | }
391 | } while ($this->liveQueue->count() > 0);
392 |
393 | $this->isProcessing = false;
394 | }
395 |
396 | public function dropSubscription(SubscriptionDropReason $reason, ?Throwable $error): void
397 | {
398 | if (! $this->isDropped) {
399 | $this->isDropped = true;
400 |
401 | if ($this->verbose) {
402 | $this->log->debug(\sprintf(
403 | 'Catch-up Subscription %s to %s: dropped subscription, reason: %s %s',
404 | $this->subscriptionName,
405 | $this->isSubscribedToAll ? '' : $this->streamId,
406 | $reason->name(),
407 | null === $error ? '' : $error->getMessage()
408 | ));
409 | }
410 |
411 | if ($this->subscription) {
412 | $this->subscription->unsubscribe();
413 | }
414 |
415 | if ($this->subscriptionDropped) {
416 | ($this->subscriptionDropped)($this, $reason, $error);
417 | }
418 |
419 | $this->stopped = true;
420 | }
421 | }
422 | }
423 |
--------------------------------------------------------------------------------
/src/Internal/EventStoreHttpConnection.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Http\Message\RequestFactory;
18 | use Prooph\EventStore\AllEventsSlice;
19 | use Prooph\EventStore\CatchUpSubscriptionSettings;
20 | use Prooph\EventStore\Common\SystemEventTypes;
21 | use Prooph\EventStore\Common\SystemStreams;
22 | use Prooph\EventStore\ConditionalWriteResult;
23 | use Prooph\EventStore\DeleteResult;
24 | use Prooph\EventStore\EventData;
25 | use Prooph\EventStore\EventId;
26 | use Prooph\EventStore\EventReadResult;
27 | use Prooph\EventStore\EventReadStatus;
28 | use Prooph\EventStore\EventStoreAllCatchUpSubscription;
29 | use Prooph\EventStore\EventStoreConnection;
30 | use Prooph\EventStore\EventStorePersistentSubscription;
31 | use Prooph\EventStore\EventStoreStreamCatchUpSubscription;
32 | use Prooph\EventStore\EventStoreSubscription;
33 | use Prooph\EventStore\EventStoreTransaction;
34 | use Prooph\EventStore\Exception\AccessDenied;
35 | use Prooph\EventStore\Exception\EventStoreConnectionException;
36 | use Prooph\EventStore\Exception\InvalidArgumentException;
37 | use Prooph\EventStore\Exception\InvalidOperationException;
38 | use Prooph\EventStore\Exception\OutOfRangeException;
39 | use Prooph\EventStore\Exception\StreamDeleted;
40 | use Prooph\EventStore\Exception\UnexpectedValueException;
41 | use Prooph\EventStore\Exception\WrongExpectedVersion;
42 | use Prooph\EventStore\ExpectedVersion;
43 | use Prooph\EventStore\Internal\Consts;
44 | use Prooph\EventStore\PersistentSubscriptionCreateResult;
45 | use Prooph\EventStore\PersistentSubscriptionCreateStatus;
46 | use Prooph\EventStore\PersistentSubscriptionDeleteResult;
47 | use Prooph\EventStore\PersistentSubscriptionDeleteStatus;
48 | use Prooph\EventStore\PersistentSubscriptionSettings;
49 | use Prooph\EventStore\PersistentSubscriptionUpdateResult;
50 | use Prooph\EventStore\PersistentSubscriptionUpdateStatus;
51 | use Prooph\EventStore\Position;
52 | use Prooph\EventStore\RawStreamMetadataResult;
53 | use Prooph\EventStore\ReadDirection;
54 | use Prooph\EventStore\SliceReadStatus;
55 | use Prooph\EventStore\StreamEventsSlice;
56 | use Prooph\EventStore\StreamMetadata;
57 | use Prooph\EventStore\StreamMetadataResult;
58 | use Prooph\EventStore\StreamPosition;
59 | use Prooph\EventStore\SystemSettings;
60 | use Prooph\EventStore\UserCredentials;
61 | use Prooph\EventStore\Util\Json;
62 | use Prooph\EventStore\WriteResult;
63 | use Prooph\EventStoreHttpClient\ConnectionSettings;
64 | use Prooph\EventStoreHttpClient\Http\HttpClient;
65 | use Prooph\EventStoreHttpClient\Internal\EventStoreAllCatchUpSubscription as EventStoreAllCatchUpSubscriptionImpl;
66 | use Prooph\EventStoreHttpClient\Internal\EventStorePersistentSubscription as EventStorePersistentSubscriptionImpl;
67 | use Prooph\EventStoreHttpClient\Internal\EventStoreStreamCatchUpSubscription as EventStoreStreamCatchUpSubscriptionImpl;
68 | use Psr\Http\Client\ClientInterface;
69 | use Throwable;
70 |
71 | /** @internal */
72 | class EventStoreHttpConnection implements EventStoreConnection
73 | {
74 | private ConnectionSettings $settings;
75 | private HttpClient $httpClient;
76 | private Closure $onException;
77 | private string $baseUri;
78 |
79 | /** @internal */
80 | public function __construct(
81 | ConnectionSettings $settings,
82 | ClientInterface $httpClient,
83 | RequestFactory $requestFactory
84 | ) {
85 | $this->baseUri = \sprintf(
86 | '%s://%s:%s',
87 | $settings->schema(),
88 | $settings->endPoint()->host(),
89 | $settings->endPoint()->port()
90 | );
91 |
92 | $this->settings = $settings;
93 |
94 | $this->httpClient = new HttpClient(
95 | $httpClient,
96 | $requestFactory,
97 | $settings,
98 | $this->baseUri
99 | );
100 |
101 | $this->onException = static function (Throwable $e) {
102 | throw new EventStoreConnectionException($e->getMessage());
103 | };
104 | }
105 |
106 | public function connectionSettings(): ConnectionSettings
107 | {
108 | return $this->settings;
109 | }
110 |
111 | /**
112 | * Note: The `DeleteResult` will always contain an invalid `Position`.
113 | *
114 | * @param string $stream
115 | * @param int $expectedVersion
116 | * @param bool $hardDelete
117 | * @param UserCredentials|null $userCredentials
118 | *
119 | * @return DeleteResult
120 | */
121 | public function deleteStream(
122 | string $stream,
123 | int $expectedVersion,
124 | bool $hardDelete = false,
125 | ?UserCredentials $userCredentials = null
126 | ): DeleteResult {
127 | if (empty($stream)) {
128 | throw new InvalidArgumentException('Stream cannot be empty');
129 | }
130 |
131 | $headers = [
132 | 'ES-ExpectedVersion' => $expectedVersion,
133 | ];
134 |
135 | if ($hardDelete) {
136 | $headers['ES-HardDelete'] = 'true';
137 | }
138 |
139 | if ($this->settings->requireMaster()) {
140 | $headers['ES-RequiresMaster'] = 'true';
141 | }
142 |
143 | $response = $this->httpClient->delete(
144 | '/streams/' . \urlencode($stream),
145 | $headers,
146 | $userCredentials,
147 | $this->onException
148 | );
149 |
150 | switch ($response->getStatusCode()) {
151 | case 204:
152 | case 410:
153 | return new DeleteResult(Position::invalid());
154 | case 400:
155 | throw WrongExpectedVersion::with($stream, $expectedVersion);
156 | case 401:
157 | throw AccessDenied::toStream($stream);
158 | default:
159 | throw new EventStoreConnectionException(\sprintf(
160 | 'Unexpected status code %d returned',
161 | $response->getStatusCode()
162 | ));
163 | }
164 | }
165 |
166 | /**
167 | * Note: The `WriteResult` will always contain ExpectedVersion::ANY with an invalid `Position`
168 | *
169 | * @param string $stream
170 | * @param int $expectedVersion
171 | * @param EventData[] $events
172 | * @param null|UserCredentials $userCredentials
173 | *
174 | * @return WriteResult
175 | */
176 | public function appendToStream(
177 | string $stream,
178 | int $expectedVersion,
179 | array $events = [],
180 | ?UserCredentials $userCredentials = null
181 | ): WriteResult {
182 | if (empty($stream)) {
183 | throw new InvalidArgumentException('Stream cannot be empty');
184 | }
185 |
186 | if (empty($events)) {
187 | return new WriteResult(ExpectedVersion::ANY, Position::invalid());
188 | }
189 |
190 | $data = [];
191 |
192 | foreach ($events as $event) {
193 | \assert($event instanceof EventData);
194 |
195 | $data[] = [
196 | 'eventId' => $event->eventId()->toString(),
197 | 'eventType' => $event->eventType(),
198 | 'data' => $event->data(),
199 | 'metadata' => $event->metaData(),
200 | ];
201 | }
202 |
203 | $body = Json::encode($data);
204 |
205 | $headers = [
206 | 'Content-Type' => 'application/vnd.eventstore.events+json',
207 | 'Content-Length' => \strlen($body),
208 | 'ES-ExpectedVersion' => $expectedVersion,
209 | ];
210 |
211 | if ($this->settings->requireMaster()) {
212 | $headers['ES-RequiresMaster'] = 'true';
213 | }
214 |
215 | $response = $this->httpClient->post(
216 | '/streams/' . \urlencode($stream),
217 | $headers,
218 | $body,
219 | $userCredentials ?? $this->settings->defaultUserCredentials(),
220 | $this->onException
221 | );
222 |
223 | switch ($response->getStatusCode()) {
224 | case 201:
225 | return new WriteResult(ExpectedVersion::ANY, Position::invalid());
226 | case 400:
227 | $header = $response->getHeader('ES-CurrentVersion');
228 |
229 | if (empty($header)) {
230 | throw new EventStoreConnectionException($response->getReasonPhrase());
231 | }
232 |
233 | $currentVersion = (int) $header[0];
234 |
235 | throw WrongExpectedVersion::with($stream, $expectedVersion, $currentVersion);
236 | case 401:
237 | throw AccessDenied::toStream($stream);
238 | case 410:
239 | throw StreamDeleted::with($stream);
240 | default:
241 | throw new EventStoreConnectionException(\sprintf(
242 | 'Unexpected status code %d returned',
243 | $response->getStatusCode()
244 | ));
245 | }
246 | }
247 |
248 | public function conditionalAppendToStream(
249 | string $stream,
250 | int $expectedVersion,
251 | array $events = [],
252 | ?UserCredentials $userCredentials = null
253 | ): ConditionalWriteResult {
254 | throw new InvalidOperationException('Not implemented on HTTP client');
255 | }
256 |
257 | public function readEvent(
258 | string $stream,
259 | int $eventNumber,
260 | bool $resolveLinkTos = true,
261 | ?UserCredentials $userCredentials = null
262 | ): EventReadResult {
263 | if (empty($stream)) {
264 | throw new InvalidArgumentException('Stream cannot be empty');
265 | }
266 |
267 | if ('$all' === $stream) {
268 | throw new InvalidArgumentException('Stream cannot be $all');
269 | }
270 |
271 | if ($eventNumber < -1) {
272 | throw new OutOfRangeException('Event number is out of range');
273 | }
274 |
275 | $headers = [
276 | 'Accept' => 'application/vnd.eventstore.atom+json',
277 | ];
278 |
279 | if ($this->settings->requireMaster()) {
280 | $headers['ES-RequiresMaster'] = 'true';
281 | }
282 |
283 | $response = $this->httpClient->get(
284 | \sprintf(
285 | '/streams/%s/%s?embed=tryharder',
286 | \urlencode($stream),
287 | -1 === $eventNumber ? 'head' : $eventNumber
288 | ),
289 | $headers,
290 | $userCredentials ?? $this->settings->defaultUserCredentials(),
291 | $this->onException
292 | );
293 |
294 | switch ($response->getStatusCode()) {
295 | case 200:
296 | $json = Json::decode($response->getBody()->getContents());
297 |
298 | if (empty($json)) {
299 | return new EventReadResult(EventReadStatus::notFound(), $stream, $eventNumber, null);
300 | }
301 |
302 | $event = ResolvedEventParser::parse($json);
303 |
304 | return new EventReadResult(EventReadStatus::success(), $stream, $eventNumber, $event);
305 | case 401:
306 | throw AccessDenied::toStream($stream);
307 | case 404:
308 | return new EventReadResult(EventReadStatus::notFound(), $stream, $eventNumber, null);
309 | case 410:
310 | return new EventReadResult(EventReadStatus::streamDeleted(), $stream, $eventNumber, null);
311 | default:
312 | throw new EventStoreConnectionException(\sprintf(
313 | 'Unexpected status code %d returned',
314 | $response->getStatusCode()
315 | ));
316 | }
317 | }
318 |
319 | public function readStreamEventsForward(
320 | string $stream,
321 | int $start,
322 | int $count,
323 | bool $resolveLinkTos = true,
324 | ?UserCredentials $userCredentials = null
325 | ): StreamEventsSlice {
326 | return $this->readStreamEventsForwardPolling(
327 | $stream,
328 | $start,
329 | $count,
330 | $resolveLinkTos,
331 | 0,
332 | $userCredentials
333 | );
334 | }
335 |
336 | public function readStreamEventsForwardPolling(
337 | string $stream,
338 | int $start,
339 | int $count,
340 | bool $resolveLinkTos = true,
341 | int $longPoll = 0,
342 | ?UserCredentials $userCredentials = null
343 | ): StreamEventsSlice {
344 | if (empty($stream)) {
345 | throw new InvalidArgumentException('Stream cannot be empty');
346 | }
347 |
348 | if ($start < 0) {
349 | throw new InvalidArgumentException('Start must be positive');
350 | }
351 |
352 | if ($count < 1) {
353 | throw new InvalidArgumentException('Count must be positive');
354 | }
355 |
356 | if ($count > Consts::MAX_READ_SIZE) {
357 | throw new InvalidArgumentException(\sprintf(
358 | 'Count should be less than %s. For larger reads you should page.',
359 | Consts::MAX_READ_SIZE
360 | ));
361 | }
362 |
363 | $headers = [
364 | 'Accept' => 'application/vnd.eventstore.atom+json',
365 | ];
366 |
367 | if (! $resolveLinkTos) {
368 | $headers['ES-ResolveLinkTos'] = 'false';
369 | }
370 |
371 | if ($this->settings->requireMaster()) {
372 | $headers['ES-RequiresMaster'] = 'true';
373 | }
374 |
375 | if ($longPoll > 0) {
376 | $headers['ES-LongPoll'] = $longPoll;
377 | }
378 |
379 | $response = $this->httpClient->get(
380 | '/streams/' . \urlencode($stream) . '/' . $start . '/forward/' . $count . '?embed=tryharder',
381 | $headers,
382 | $userCredentials ?? $this->settings->defaultUserCredentials(),
383 | $this->onException
384 | );
385 |
386 | switch ($response->getStatusCode()) {
387 | case 401:
388 | throw AccessDenied::toStream($stream);
389 | case 404:
390 | return new StreamEventsSlice(
391 | SliceReadStatus::streamNotFound(),
392 | $stream,
393 | $start,
394 | ReadDirection::forward(),
395 | [],
396 | -1,
397 | -1,
398 | true
399 | );
400 | case 410:
401 | return new StreamEventsSlice(
402 | SliceReadStatus::streamDeleted(),
403 | $stream,
404 | $start,
405 | ReadDirection::forward(),
406 | [],
407 | -1,
408 | -1,
409 | true
410 | );
411 | case 200:
412 | $contents = $response->getBody()->getContents();
413 | $json = Json::decode($contents);
414 |
415 | $events = [];
416 | $lastEventNumber = $start - 1;
417 |
418 | foreach (\array_reverse($json['entries']) as $entry) {
419 | $events[] = ResolvedEventParser::parse($entry);
420 |
421 | if (! isset($entry['eventNumber'])) {
422 | $matches = [];
423 | \preg_match('/^(\d+)@(.+)$/', $entry['title'], $matches);
424 | $entry['eventNumber'] = $matches[1];
425 | }
426 |
427 | $lastEventNumber = (int) $entry['eventNumber'];
428 | }
429 |
430 | return new StreamEventsSlice(
431 | SliceReadStatus::success(),
432 | $stream,
433 | $start,
434 | ReadDirection::forward(),
435 | $events,
436 | $lastEventNumber + 1,
437 | $lastEventNumber,
438 | $json['headOfStream']
439 | );
440 | default:
441 | throw new EventStoreConnectionException(\sprintf(
442 | 'Unexpected status code %d returned',
443 | $response->getStatusCode()
444 | ));
445 | }
446 | }
447 |
448 | public function readStreamEventsBackward(
449 | string $stream,
450 | int $start,
451 | int $count,
452 | bool $resolveLinkTos = true,
453 | ?UserCredentials $userCredentials = null
454 | ): StreamEventsSlice {
455 | if (empty($stream)) {
456 | throw new InvalidArgumentException('Stream cannot be empty');
457 | }
458 |
459 | if ($count < 1) {
460 | throw new InvalidArgumentException('Count must be positive');
461 | }
462 |
463 | if ($count > Consts::MAX_READ_SIZE) {
464 | throw new InvalidArgumentException(\sprintf(
465 | 'Count should be less than %s. For larger reads you should page.',
466 | Consts::MAX_READ_SIZE
467 | ));
468 | }
469 |
470 | $headers = [
471 | 'Accept' => 'application/vnd.eventstore.atom+json',
472 | ];
473 |
474 | if (! $resolveLinkTos) {
475 | $headers['ES-ResolveLinkTos'] = 'false';
476 | }
477 |
478 | if ($this->settings->requireMaster()) {
479 | $headers['ES-RequiresMaster'] = 'true';
480 | }
481 |
482 | $response = $this->httpClient->get(
483 | \sprintf(
484 | '/streams/%s/%s/backward/%d?embed=tryharder',
485 | \urlencode($stream),
486 | -1 === $start ? 'head' : $start,
487 | $count
488 | ),
489 | $headers,
490 | $userCredentials ?? $this->settings->defaultUserCredentials(),
491 | $this->onException
492 | );
493 |
494 | switch ($response->getStatusCode()) {
495 | case 200:
496 | $json = Json::decode($response->getBody()->getContents());
497 |
498 | $events = [];
499 | $lastEventNumber = 0;
500 | foreach ($json['entries'] as $entry) {
501 | $events[] = ResolvedEventParser::parse($entry);
502 |
503 | $lastEventNumber = $entry['eventNumber'];
504 | }
505 | $nextEventNumber = ($lastEventNumber < 1) ? 0 : ($lastEventNumber - 1);
506 |
507 | return new StreamEventsSlice(
508 | SliceReadStatus::success(),
509 | $stream,
510 | $start,
511 | ReadDirection::backward(),
512 | $events,
513 | $nextEventNumber,
514 | $lastEventNumber,
515 | false
516 | );
517 | case 401:
518 | throw AccessDenied::toStream($stream);
519 | case 404:
520 | return new StreamEventsSlice(
521 | SliceReadStatus::streamNotFound(),
522 | $stream,
523 | $start,
524 | ReadDirection::backward(),
525 | [],
526 | -1,
527 | -1,
528 | true
529 | );
530 | case 410:
531 | return new StreamEventsSlice(
532 | SliceReadStatus::streamDeleted(),
533 | $stream,
534 | $start,
535 | ReadDirection::backward(),
536 | [],
537 | -1,
538 | -1,
539 | true
540 | );
541 | default:
542 | throw new EventStoreConnectionException(\sprintf(
543 | 'Unexpected status code %d returned',
544 | $response->getStatusCode()
545 | ));
546 | }
547 | }
548 |
549 | public function readAllEventsForward(
550 | Position $position,
551 | int $count,
552 | bool $resolveLinkTos = true,
553 | ?UserCredentials $userCredentials = null
554 | ): AllEventsSlice {
555 | return $this->readAllEventsForwardPolling(
556 | $position,
557 | $count,
558 | $resolveLinkTos,
559 | 0,
560 | $userCredentials
561 | );
562 | }
563 |
564 | public function readAllEventsForwardPolling(
565 | Position $position,
566 | int $count,
567 | bool $resolveLinkTos = true,
568 | int $longPoll = 0,
569 | ?UserCredentials $userCredentials = null
570 | ): AllEventsSlice {
571 | if ($count < 1) {
572 | throw new InvalidArgumentException('Count must be positive');
573 | }
574 |
575 | if ($count > Consts::MAX_READ_SIZE) {
576 | throw new InvalidArgumentException(\sprintf(
577 | 'Count should be less than %s. For larger reads you should page.',
578 | Consts::MAX_READ_SIZE
579 | ));
580 | }
581 |
582 | if ($position->equals(Position::end())) {
583 | return new AllEventsSlice(ReadDirection::forward(), $position, $position, []);
584 | }
585 |
586 | $headers = [
587 | 'Accept' => 'application/vnd.eventstore.atom+json',
588 | ];
589 |
590 | if (! $resolveLinkTos) {
591 | $headers['ES-ResolveLinkTos'] = 'false';
592 | }
593 |
594 | if ($this->settings->requireMaster()) {
595 | $headers['ES-RequiresMaster'] = 'true';
596 | }
597 |
598 | if ($longPoll > 0) {
599 | $headers['ES-LongPoll'] = $longPoll;
600 | }
601 |
602 | $response = $this->httpClient->get(
603 | '/streams/%24all' . '/' . $position->asString() . '/forward/' . $count . '?embed=tryharder',
604 | $headers,
605 | $userCredentials ?? $this->settings->defaultUserCredentials(),
606 | $this->onException
607 | );
608 |
609 | switch ($response->getStatusCode()) {
610 | case 401:
611 | throw AccessDenied::toStream('$all');
612 | case 200:
613 | $json = Json::decode($response->getBody()->getContents());
614 |
615 | foreach ($json['links'] as $link) {
616 | if ($link['relation'] === 'previous') {
617 | $start = \strlen($this->baseUri . '/streams/%24all' . '/');
618 | $nextPosition = Position::parse(\substr($link['uri'], $start, 32));
619 | }
620 | }
621 |
622 | $events = [];
623 | foreach (\array_reverse($json['entries']) as $entry) {
624 | $events[] = ResolvedEventParser::parse($entry);
625 | }
626 |
627 | return new AllEventsSlice(
628 | ReadDirection::forward(),
629 | $position,
630 | $nextPosition,
631 | $events
632 | );
633 | default:
634 | throw new EventStoreConnectionException(\sprintf(
635 | 'Unexpected status code %d returned',
636 | $response->getStatusCode()
637 | ));
638 | }
639 | }
640 |
641 | public function readAllEventsBackward(
642 | Position $position,
643 | int $count,
644 | bool $resolveLinkTos = true,
645 | ?UserCredentials $userCredentials = null
646 | ): AllEventsSlice {
647 | if ($count < 1) {
648 | throw new InvalidArgumentException('Count must be positive');
649 | }
650 |
651 | if ($count > Consts::MAX_READ_SIZE) {
652 | throw new InvalidArgumentException(\sprintf(
653 | 'Count should be less than %s. For larger reads you should page.',
654 | Consts::MAX_READ_SIZE
655 | ));
656 | }
657 |
658 | $headers = [
659 | 'Accept' => 'application/vnd.eventstore.atom+json',
660 | ];
661 |
662 | if (! $resolveLinkTos) {
663 | $headers['ES-ResolveLinkTos'] = 'false';
664 | }
665 |
666 | if ($this->settings->requireMaster()) {
667 | $headers['ES-RequiresMaster'] = 'true';
668 | }
669 |
670 | $response = $this->httpClient->get(
671 | \sprintf(
672 | '/streams/%s/%s/backward/%d?embed=tryharder',
673 | \urlencode('$all'),
674 | $position->equals(Position::end()) ? 'head' : $position->asString(),
675 | $count
676 | ),
677 | $headers,
678 | $userCredentials ?? $this->settings->defaultUserCredentials(),
679 | $this->onException
680 | );
681 |
682 | switch ($response->getStatusCode()) {
683 | case 401:
684 | throw AccessDenied::toStream('$all');
685 | case 200:
686 | $json = Json::decode($response->getBody()->getContents());
687 |
688 | foreach ($json['links'] as $link) {
689 | if ($link['relation'] === 'next') {
690 | $start = \strlen($this->baseUri . '/streams/%24all' . '/');
691 | $nextPosition = Position::parse(\substr($link['uri'], $start, 32));
692 | }
693 | }
694 |
695 | $events = [];
696 | foreach ($json['entries'] as $entry) {
697 | $events[] = ResolvedEventParser::parse($entry);
698 | }
699 |
700 | return new AllEventsSlice(
701 | ReadDirection::backward(),
702 | $position,
703 | $nextPosition,
704 | $events
705 | );
706 | default:
707 | throw new EventStoreConnectionException(\sprintf(
708 | 'Unexpected status code %d returned',
709 | $response->getStatusCode()
710 | ));
711 | }
712 | }
713 |
714 | /**
715 | * Note: The `WriteResult` will always contain ExpectedVersion::ANY with an invalid `Position`
716 | *
717 | * @param string $stream
718 | * @param int $expectedMetaStreamVersion
719 | * @param StreamMetadata|null $metadata
720 | * @param UserCredentials|null $userCredentials
721 | *
722 | * @return WriteResult
723 | */
724 | public function setStreamMetadata(
725 | string $stream,
726 | int $expectedMetaStreamVersion,
727 | ?StreamMetadata $metadata = null,
728 | ?UserCredentials $userCredentials = null
729 | ): WriteResult {
730 | $string = $metadata ? Json::encode($metadata) : '';
731 |
732 | return $this->setRawStreamMetadata(
733 | $stream,
734 | $expectedMetaStreamVersion,
735 | $string,
736 | $userCredentials
737 | );
738 | }
739 |
740 | /**
741 | * Note: The `WriteResult` will always contain ExpectedVersion::ANY with an invalid `Position`
742 | *
743 | * @param string $stream
744 | * @param int $expectedMetaStreamVersion
745 | * @param string $metadata
746 | * @param UserCredentials|null $userCredentials
747 | *
748 | * @return WriteResult
749 | */
750 | public function setRawStreamMetadata(
751 | string $stream,
752 | int $expectedMetaStreamVersion,
753 | string $metadata = '',
754 | ?UserCredentials $userCredentials = null
755 | ): WriteResult {
756 | if (empty($stream)) {
757 | throw new InvalidArgumentException('Stream cannot be empty');
758 | }
759 |
760 | if (SystemStreams::isMetastream($stream)) {
761 | throw new InvalidArgumentException(\sprintf(
762 | 'Setting metadata for metastream \'%s\' is not supported.',
763 | $stream
764 | ));
765 | }
766 |
767 | $headers = [
768 | 'ES-ExpectedVersion' => $expectedMetaStreamVersion,
769 | 'Content-Type' => 'application/json',
770 | 'ES-EventId' => EventId::generate()->toString(),
771 | ];
772 |
773 | if ($this->settings->requireMaster()) {
774 | $headers['ES-RequiresMaster'] = 'true';
775 | }
776 |
777 | $response = $this->httpClient->post(
778 | '/streams/' . \urlencode($stream) . '/metadata',
779 | $headers,
780 | $metadata,
781 | $userCredentials ?? $this->settings->defaultUserCredentials(),
782 | $this->onException
783 | );
784 |
785 | switch ($response->getStatusCode()) {
786 | case 201:
787 | return new WriteResult(ExpectedVersion::ANY, Position::invalid());
788 | case 400:
789 | $header = $response->getHeader('ES-CurrentVersion');
790 |
791 | if (empty($header)) {
792 | throw new EventStoreConnectionException($response->getReasonPhrase());
793 | }
794 |
795 | $currentVersion = (int) $header[0];
796 |
797 | throw WrongExpectedVersion::with($stream, $expectedMetaStreamVersion, $currentVersion);
798 | case 401:
799 | throw AccessDenied::toStream($stream);
800 | case 410:
801 | throw StreamDeleted::with($stream);
802 | default:
803 | throw new EventStoreConnectionException(\sprintf(
804 | 'Unexpected status code %d returned',
805 | $response->getStatusCode()
806 | ));
807 | }
808 | }
809 |
810 | public function getStreamMetadata(
811 | string $stream,
812 | ?UserCredentials $userCredentials = null
813 | ): StreamMetadataResult {
814 | $result = $this->getRawStreamMetadata($stream, $userCredentials);
815 |
816 | if ($result->streamMetadata() === '') {
817 | return new StreamMetadataResult(
818 | $result->stream(),
819 | $result->isStreamDeleted(),
820 | $result->metastreamVersion(),
821 | new StreamMetadata()
822 | );
823 | }
824 |
825 | $metadata = StreamMetadata::createFromArray(Json::decode($result->streamMetadata()));
826 |
827 | return new StreamMetadataResult(
828 | $result->stream(),
829 | $result->isStreamDeleted(),
830 | $result->metastreamVersion(),
831 | $metadata
832 | );
833 | }
834 |
835 | public function getRawStreamMetadata(
836 | string $stream,
837 | ?UserCredentials $userCredentials = null
838 | ): RawStreamMetadataResult {
839 | if (empty($stream)) {
840 | throw new InvalidArgumentException('Stream cannot be empty');
841 | }
842 |
843 | $eventReadResult = $this->readEvent(
844 | SystemStreams::metastreamOf($stream),
845 | -1,
846 | false,
847 | $userCredentials
848 | );
849 |
850 | switch ($eventReadResult->status()->value()) {
851 | case EventReadStatus::SUCCESS:
852 | $event = $eventReadResult->event();
853 |
854 | if (null === $event) {
855 | throw new UnexpectedValueException('Event is null while operation result is Success');
856 | }
857 |
858 | $event = $event->originalEvent();
859 |
860 | if (null === $event) {
861 | return new RawStreamMetadataResult(
862 | $stream,
863 | false,
864 | -1,
865 | ''
866 | );
867 | }
868 |
869 | return new RawStreamMetadataResult(
870 | $stream,
871 | false,
872 | $event->eventNumber(),
873 | $event->data()
874 | );
875 | case EventReadStatus::NOT_FOUND:
876 | case EventReadStatus::NO_STREAM:
877 | return new RawStreamMetadataResult($stream, false, -1, '');
878 | case EventReadStatus::STREAM_DELETED:
879 | return new RawStreamMetadataResult($stream, true, \PHP_INT_MAX, '');
880 | default:
881 | throw new OutOfRangeException(\sprintf(
882 | 'Unexpected ReadEventResult: %s',
883 | $eventReadResult->status()->name()
884 | ));
885 | }
886 | }
887 |
888 | /**
889 | * Note: The `WriteResult` will always contain ExpectedVersion::ANY with an invalid `Position`
890 | *
891 | * @param SystemSettings $settings
892 | * @param UserCredentials|null $userCredentials
893 | *
894 | * @return WriteResult
895 | */
896 | public function setSystemSettings(
897 | SystemSettings $settings,
898 | ?UserCredentials $userCredentials = null
899 | ): WriteResult {
900 | return $this->appendToStream(
901 | SystemStreams::SETTINGS_STREAM,
902 | ExpectedVersion::ANY,
903 | [new EventData(null, SystemEventTypes::SETTINGS, true, Json::encode($settings))],
904 | $userCredentials
905 | );
906 | }
907 |
908 | public function createPersistentSubscription(
909 | string $stream,
910 | string $groupName,
911 | PersistentSubscriptionSettings $settings,
912 | ?UserCredentials $userCredentials = null
913 | ): PersistentSubscriptionCreateResult {
914 | if (empty($stream)) {
915 | throw new InvalidArgumentException('Stream cannot be empty');
916 | }
917 |
918 | if (empty($groupName)) {
919 | throw new InvalidArgumentException('Group name cannot be empty');
920 | }
921 |
922 | if ('$all' === $stream) {
923 | throw AccessDenied::toStream($stream);
924 | }
925 |
926 | $body = Json::encode($settings);
927 |
928 | $headers = [
929 | 'Content-Type' => 'application/json',
930 | 'Content-Length' => \strlen($body),
931 | ];
932 |
933 | if ($this->settings->requireMaster()) {
934 | $headers['ES-RequiresMaster'] = 'true';
935 | }
936 |
937 | $response = $this->httpClient->put(
938 | '/subscriptions/' . \urlencode($stream) . '/' . \urlencode($groupName),
939 | $headers,
940 | $body,
941 | $userCredentials ?? $this->settings->defaultUserCredentials(),
942 | $this->onException
943 | );
944 |
945 | switch ($response->getStatusCode()) {
946 | case 401:
947 | throw AccessDenied::toStream($stream);
948 | case 201:
949 | return new PersistentSubscriptionCreateResult(
950 | PersistentSubscriptionCreateStatus::success()
951 | );
952 | case 409:
953 | throw new InvalidOperationException(\sprintf(
954 | 'Subscription group \'%s\' on stream \'%s\' failed \'%s\'',
955 | $groupName,
956 | $stream,
957 | $response->getReasonPhrase()
958 | ));
959 | default:
960 | \var_dump($response);
961 | throw new EventStoreConnectionException(\sprintf(
962 | 'Unexpected status code %d returned',
963 | $response->getStatusCode()
964 | ));
965 | }
966 | }
967 |
968 | public function updatePersistentSubscription(
969 | string $stream,
970 | string $groupName,
971 | PersistentSubscriptionSettings $settings,
972 | ?UserCredentials $userCredentials = null
973 | ): PersistentSubscriptionUpdateResult {
974 | if (empty($stream)) {
975 | throw new InvalidArgumentException('Stream cannot be empty');
976 | }
977 |
978 | if (empty($groupName)) {
979 | throw new InvalidArgumentException('Group name cannot be empty');
980 | }
981 |
982 | $body = Json::encode($settings);
983 |
984 | $headers = [
985 | 'Content-Type' => 'application/json',
986 | 'Content-Length' => \strlen($body),
987 | ];
988 |
989 | if ($this->settings->requireMaster()) {
990 | $headers['ES-RequiresMaster'] = 'true';
991 | }
992 |
993 | $response = $this->httpClient->post(
994 | '/subscriptions/' . \urlencode($stream) . '/' . \urlencode($groupName),
995 | $headers,
996 | $body,
997 | $userCredentials ?? $this->settings->defaultUserCredentials(),
998 | $this->onException
999 | );
1000 |
1001 | switch ($response->getStatusCode()) {
1002 | case 200:
1003 | return new PersistentSubscriptionUpdateResult(
1004 | PersistentSubscriptionUpdateStatus::success()
1005 | );
1006 | case 401:
1007 | throw AccessDenied::toStream($stream);
1008 | case 404:
1009 | throw new InvalidOperationException(\sprintf(
1010 | 'Subscription group \'%s\' on stream \'%s\' does not exist',
1011 | $groupName,
1012 | $stream
1013 | ));
1014 | default:
1015 | throw new EventStoreConnectionException(\sprintf(
1016 | 'Unexpected status code %d returned',
1017 | $response->getStatusCode()
1018 | ));
1019 | }
1020 | }
1021 |
1022 | public function deletePersistentSubscription(
1023 | string $stream,
1024 | string $groupName,
1025 | ?UserCredentials $userCredentials = null
1026 | ): PersistentSubscriptionDeleteResult {
1027 | if (empty($stream)) {
1028 | throw new InvalidArgumentException('Stream cannot be empty');
1029 | }
1030 |
1031 | if (empty($groupName)) {
1032 | throw new InvalidArgumentException('Group name cannot be empty');
1033 | }
1034 |
1035 | $headers = [];
1036 |
1037 | if ($this->settings->requireMaster()) {
1038 | $headers['ES-RequiresMaster'] = 'true';
1039 | }
1040 |
1041 | $response = $this->httpClient->delete(
1042 | '/subscriptions/' . \urlencode($stream) . '/' . \urlencode($groupName),
1043 | $headers,
1044 | $userCredentials ?? $this->settings->defaultUserCredentials(),
1045 | $this->onException
1046 | );
1047 |
1048 | switch ($response->getStatusCode()) {
1049 | case 401:
1050 | throw AccessDenied::toStream($stream);
1051 | case 200:
1052 | return new PersistentSubscriptionDeleteResult(
1053 | PersistentSubscriptionDeleteStatus::success()
1054 | );
1055 | case 404:
1056 | throw new InvalidOperationException(\sprintf(
1057 | 'Subscription group \'%s\' on stream \'%s\' failed \'%s\'',
1058 | $groupName,
1059 | $stream,
1060 | $response->getReasonPhrase()
1061 | ));
1062 | default:
1063 | throw new EventStoreConnectionException(\sprintf(
1064 | 'Unexpected status code %d returned',
1065 | $response->getStatusCode()
1066 | ));
1067 | }
1068 | }
1069 |
1070 | public function startTransaction(
1071 | string $stream,
1072 | int $expectedVersion,
1073 | ?UserCredentials $userCredentials = null
1074 | ): EventStoreTransaction {
1075 | throw new InvalidOperationException('Not implemented on HTTP client');
1076 | }
1077 |
1078 | public function continueTransaction(
1079 | int $transactionId,
1080 | ?UserCredentials $userCredentials = null
1081 | ): EventStoreTransaction {
1082 | throw new InvalidOperationException('Not implemented on HTTP client');
1083 | }
1084 |
1085 | public function subscribeToStream(
1086 | string $stream,
1087 | bool $resolveLinkTos,
1088 | Closure $eventAppeared,
1089 | ?Closure $subscriptionDropped = null,
1090 | ?UserCredentials $userCredentials = null
1091 | ): EventStoreSubscription {
1092 | $streamEventsSlice = $this->readStreamEventsBackward(
1093 | $stream,
1094 | StreamPosition::END,
1095 | 1,
1096 | $resolveLinkTos,
1097 | $userCredentials
1098 | );
1099 |
1100 | $lastEventNumber = StreamPosition::START;
1101 |
1102 | if ($streamEventsSlice->status()->equals(SliceReadStatus::success())
1103 | && ! empty($streamEventsSlice->events())
1104 | ) {
1105 | $lastEvent = $streamEventsSlice->events()[0];
1106 | $lastEventNumber = $lastEvent->originalEventNumber() + 1;
1107 | }
1108 |
1109 | return new VolatileEventStoreStreamSubscription(
1110 | $this,
1111 | $eventAppeared,
1112 | $subscriptionDropped,
1113 | $stream,
1114 | $lastEventNumber,
1115 | $resolveLinkTos,
1116 | $userCredentials
1117 | );
1118 | }
1119 |
1120 | public function subscribeToStreamFrom(
1121 | string $stream,
1122 | ?int $lastCheckpoint,
1123 | ?CatchUpSubscriptionSettings $settings,
1124 | Closure $eventAppeared,
1125 | ?Closure $liveProcessingStarted = null,
1126 | ?Closure $subscriptionDropped = null,
1127 | ?UserCredentials $userCredentials = null
1128 | ): EventStoreStreamCatchUpSubscription {
1129 | if (empty($stream)) {
1130 | throw new InvalidArgumentException('Stream cannot be empty');
1131 | }
1132 |
1133 | if (null === $settings) {
1134 | $settings = CatchUpSubscriptionSettings::default();
1135 | }
1136 |
1137 | return new EventStoreStreamCatchUpSubscriptionImpl(
1138 | $this,
1139 | $this->settings->log(),
1140 | $stream,
1141 | $lastCheckpoint,
1142 | $userCredentials,
1143 | $eventAppeared,
1144 | $liveProcessingStarted,
1145 | $subscriptionDropped,
1146 | $settings
1147 | );
1148 | }
1149 |
1150 | public function subscribeToAll(
1151 | bool $resolveLinkTos,
1152 | Closure $eventAppeared,
1153 | ?Closure $subscriptionDropped = null,
1154 | ?UserCredentials $userCredentials = null
1155 | ): EventStoreSubscription {
1156 | $allEventsSlice = $this->readAllEventsBackward(
1157 | Position::end(),
1158 | 1,
1159 | $resolveLinkTos,
1160 | $userCredentials
1161 | );
1162 |
1163 | return new VolatileEventStoreAllSubscription(
1164 | $this,
1165 | $eventAppeared,
1166 | $subscriptionDropped,
1167 | '',
1168 | $allEventsSlice->nextPosition(),
1169 | $resolveLinkTos,
1170 | $userCredentials
1171 | );
1172 | }
1173 |
1174 | public function subscribeToAllFrom(
1175 | ?Position $lastCheckpoint,
1176 | ?CatchUpSubscriptionSettings $settings,
1177 | Closure $eventAppeared,
1178 | ?Closure $liveProcessingStarted = null,
1179 | ?Closure $subscriptionDropped = null,
1180 | ?UserCredentials $userCredentials = null
1181 | ): EventStoreAllCatchUpSubscription {
1182 | if (null === $settings) {
1183 | $settings = CatchUpSubscriptionSettings::default();
1184 | }
1185 |
1186 | return new EventStoreAllCatchUpSubscriptionImpl(
1187 | $this,
1188 | $this->settings->log(),
1189 | $lastCheckpoint,
1190 | $userCredentials,
1191 | $eventAppeared,
1192 | $liveProcessingStarted,
1193 | $subscriptionDropped,
1194 | $settings
1195 | );
1196 | }
1197 |
1198 | public function connectToPersistentSubscription(
1199 | string $stream,
1200 | string $groupName,
1201 | Closure $eventAppeared,
1202 | ?Closure $subscriptionDropped = null,
1203 | int $bufferSize = 10,
1204 | bool $autoAck = true,
1205 | ?UserCredentials $userCredentials = null
1206 | ): EventStorePersistentSubscription {
1207 | if (empty($stream)) {
1208 | throw new InvalidArgumentException('Stream cannot be empty');
1209 | }
1210 |
1211 | if (empty($groupName)) {
1212 | throw new InvalidArgumentException('Group cannot be empty');
1213 | }
1214 |
1215 | return new EventStorePersistentSubscriptionImpl(
1216 | $this->httpClient,
1217 | $groupName,
1218 | $stream,
1219 | $eventAppeared,
1220 | $subscriptionDropped,
1221 | $userCredentials,
1222 | $bufferSize,
1223 | $autoAck
1224 | );
1225 | }
1226 | }
1227 |
--------------------------------------------------------------------------------
/src/Internal/EventStorePersistentSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\EventId;
18 | use Prooph\EventStore\EventStorePersistentSubscription as EventStorePersistentSubscriptionInterface;
19 | use Prooph\EventStore\Exception\RuntimeException;
20 | use Prooph\EventStore\Internal\DropData;
21 | use Prooph\EventStore\Internal\PersistentEventStoreSubscription;
22 | use Prooph\EventStore\Internal\ResolvedEvent as InternalResolvedEvent;
23 | use Prooph\EventStore\PersistentSubscriptionNakEventAction;
24 | use Prooph\EventStore\PersistentSubscriptionResolvedEvent;
25 | use Prooph\EventStore\ResolvedEvent;
26 | use Prooph\EventStore\SubscriptionDropReason;
27 | use Prooph\EventStore\UserCredentials;
28 | use Prooph\EventStoreHttpClient\Http\HttpClient;
29 | use ReflectionProperty;
30 | use SplQueue;
31 | use Throwable;
32 |
33 | class EventStorePersistentSubscription implements EventStorePersistentSubscriptionInterface
34 | {
35 | private HttpClient $httpClient;
36 |
37 | private ?ResolvedEvent $dropSubscriptionEvent = null;
38 |
39 | private string $subscriptionId;
40 | private string $streamId;
41 | private Closure $eventAppeared;
42 | private ?Closure $subscriptionDropped;
43 | private ?UserCredentials $userCredentials;
44 | private bool $autoAck;
45 |
46 | private PersistentEventStoreSubscription $subscription;
47 | private SplQueue $queue;
48 | private bool $isProcessing = false;
49 | private ?DropData $dropData = null;
50 |
51 | private bool $isDropped = false;
52 | private int $bufferSize;
53 | private bool $stopped = true;
54 |
55 | /**
56 | * @internal
57 | *
58 | * @param Closure(EventStorePersistentSubscription, ResolvedEvent, null|int): void $eventAppeared
59 | * @param null|Closure(EventStorePersistentSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
60 | */
61 | public function __construct(
62 | HttpClient $httpClient,
63 | string $subscriptionId,
64 | string $streamId,
65 | Closure $eventAppeared,
66 | ?Closure $subscriptionDropped,
67 | ?UserCredentials $userCredentials,
68 | int $bufferSize = 10,
69 | bool $autoAck = true
70 | ) {
71 | if (null === $this->dropSubscriptionEvent) {
72 | $this->dropSubscriptionEvent = new ResolvedEvent(null, null, null);
73 | }
74 |
75 | $this->httpClient = $httpClient;
76 | $this->subscriptionId = $subscriptionId;
77 | $this->streamId = $streamId;
78 | $this->eventAppeared = $eventAppeared;
79 | $this->subscriptionDropped = $subscriptionDropped;
80 | $this->userCredentials = $userCredentials;
81 | $this->bufferSize = $bufferSize;
82 | $this->autoAck = $autoAck;
83 | $this->queue = new SplQueue();
84 | }
85 |
86 | /** @internal */
87 | public function startSubscription(
88 | string $subscriptionId,
89 | string $streamId,
90 | int $bufferSize,
91 | ?UserCredentials $userCredentials,
92 | callable $onEventAppeared,
93 | ?callable $onSubscriptionDropped
94 | ): PersistentEventStoreSubscription {
95 | $operation = new ConnectToPersistentSubscriptionOperation(
96 | $this->httpClient,
97 | $subscriptionId,
98 | $bufferSize,
99 | $streamId,
100 | $userCredentials,
101 | $onEventAppeared,
102 | $onSubscriptionDropped
103 | );
104 |
105 | return $operation->createSubscriptionObject();
106 | }
107 |
108 | public function start(): void
109 | {
110 | $this->stopped = false;
111 |
112 | $eventAppeared = function (
113 | PersistentEventStoreSubscription $subscription,
114 | PersistentSubscriptionResolvedEvent $resolvedEvent
115 | ): void {
116 | $this->onEventAppeared($resolvedEvent);
117 | };
118 |
119 | $subscriptionDropped = function (
120 | PersistentEventStoreSubscription $subscription,
121 | SubscriptionDropReason $reason,
122 | ?Throwable $exception
123 | ): void {
124 | $this->onSubscriptionDropped($reason, $exception);
125 | };
126 |
127 | /** @var PersistentEventStoreSubscription $subscription */
128 | $this->subscription = $subscription = $this->startSubscription(
129 | $this->subscriptionId,
130 | $this->streamId,
131 | $this->bufferSize,
132 | $this->userCredentials,
133 | $eventAppeared,
134 | $subscriptionDropped
135 | );
136 |
137 | // @todo dirty hack
138 | $property = new ReflectionProperty($subscription, 'subscriptionOperation');
139 | $property->setAccessible(true);
140 |
141 | $operation = $property->getValue($subscription);
142 |
143 | while (! $this->isDropped) {
144 | foreach ($operation->readFromSubscription($this->bufferSize) as $event) {
145 | $this->enqueue($event);
146 | }
147 |
148 | $this->processQueue();
149 | }
150 | }
151 |
152 | /**
153 | * Acknowledge that a message have completed processing (this will tell the server it has been processed)
154 | * Note: There is no need to ack a message if you have Auto Ack enabled
155 | *
156 | * @param InternalResolvedEvent $event
157 | *
158 | * @return void
159 | */
160 | public function acknowledge(InternalResolvedEvent $event): void
161 | {
162 | $this->subscription->notifyEventsProcessed([$event->originalEvent()->eventId()]);
163 | }
164 |
165 | /**
166 | * Acknowledge that a message have completed processing (this will tell the server it has been processed)
167 | * Note: There is no need to ack a message if you have Auto Ack enabled
168 | *
169 | * @param InternalResolvedEvent[] $events
170 | *
171 | * @return void
172 | */
173 | public function acknowledgeMultiple(array $events): void
174 | {
175 | $ids = \array_map(
176 | function (InternalResolvedEvent $event): EventId {
177 | return $event->originalEvent()->eventId();
178 | },
179 | $events
180 | );
181 |
182 | $this->subscription->notifyEventsProcessed($ids);
183 | }
184 |
185 | /**
186 | * Acknowledge that a message have completed processing (this will tell the server it has been processed)
187 | * Note: There is no need to ack a message if you have Auto Ack enabled
188 | *
189 | * @param EventId $eventId
190 | *
191 | * @return void
192 | */
193 | public function acknowledgeEventId(EventId $eventId): void
194 | {
195 | $this->subscription->notifyEventsProcessed([$eventId]);
196 | }
197 |
198 | /**
199 | * Acknowledge that a message have completed processing (this will tell the server it has been processed)
200 | * Note: There is no need to ack a message if you have Auto Ack enabled
201 | *
202 | * @param EventId[] $eventIds
203 | *
204 | * @return void
205 | */
206 | public function acknowledgeMultipleEventIds(array $eventIds): void
207 | {
208 | $this->subscription->notifyEventsProcessed($eventIds);
209 | }
210 |
211 | /**
212 | * Mark a message failed processing. The server will be take action based upon the action paramter
213 | */
214 | public function fail(
215 | InternalResolvedEvent $event,
216 | PersistentSubscriptionNakEventAction $action,
217 | string $reason
218 | ): void {
219 | $this->subscription->notifyEventsFailed([$event->originalEvent()->eventId()], $action, $reason);
220 | }
221 |
222 | /**
223 | * Mark n messages that have failed processing. The server will take action based upon the action parameter
224 | *
225 | * @param InternalResolvedEvent[] $events
226 | * @param PersistentSubscriptionNakEventAction $action
227 | * @param string $reason
228 | */
229 | public function failMultiple(
230 | array $events,
231 | PersistentSubscriptionNakEventAction $action,
232 | string $reason
233 | ): void {
234 | $ids = \array_map(
235 | function (InternalResolvedEvent $event): EventId {
236 | return $event->originalEvent()->eventId();
237 | },
238 | $events
239 | );
240 |
241 | $this->subscription->notifyEventsFailed($ids, $action, $reason);
242 | }
243 |
244 | public function failEventId(EventId $eventId, PersistentSubscriptionNakEventAction $action, string $reason): void
245 | {
246 | $this->subscription->notifyEventsFailed([$eventId], $action, $reason);
247 | }
248 |
249 | public function failMultipleEventIds(array $eventIds, PersistentSubscriptionNakEventAction $action, string $reason): void
250 | {
251 | foreach ($eventIds as $eventId) {
252 | \assert($eventId instanceof EventId);
253 | }
254 |
255 | $this->subscription->notifyEventsFailed($eventIds, $action, $reason);
256 | }
257 |
258 | public function stop(): void
259 | {
260 | $this->enqueueSubscriptionDropNotification(SubscriptionDropReason::userInitiated(), null);
261 | }
262 |
263 | private function enqueueSubscriptionDropNotification(
264 | SubscriptionDropReason $reason,
265 | ?Throwable $error
266 | ): void {
267 | // if drop data was already set -- no need to enqueue drop again, somebody did that already
268 | if (null === $this->dropData) {
269 | $this->dropData = new DropData($reason, $error);
270 |
271 | $this->enqueue(
272 | new PersistentSubscriptionResolvedEvent($this->dropSubscriptionEvent, null)
273 | );
274 | }
275 | }
276 |
277 | private function onSubscriptionDropped(
278 | SubscriptionDropReason $reason,
279 | ?Throwable $exception): void
280 | {
281 | $this->enqueueSubscriptionDropNotification($reason, $exception);
282 | }
283 |
284 | private function onEventAppeared(
285 | PersistentSubscriptionResolvedEvent $resolvedEvent
286 | ): void {
287 | $this->enqueue($resolvedEvent);
288 | }
289 |
290 | private function enqueue(PersistentSubscriptionResolvedEvent $resolvedEvent): void
291 | {
292 | $this->queue[] = $resolvedEvent;
293 |
294 | if (! $this->isProcessing) {
295 | $this->isProcessing = true;
296 |
297 | $this->processQueue();
298 | }
299 | }
300 |
301 | private function processQueue(): void
302 | {
303 | do {
304 | while (! $this->queue->isEmpty()) {
305 | $e = $this->queue->dequeue();
306 | \assert($e instanceof PersistentSubscriptionResolvedEvent);
307 |
308 | if ($e->event() === $this->dropSubscriptionEvent) {
309 | // drop subscription artificial ResolvedEvent
310 |
311 | if (null === $this->dropData) {
312 | throw new RuntimeException('Drop reason not specified');
313 | }
314 |
315 | $this->dropSubscription($this->dropData->reason(), $this->dropData->error());
316 |
317 | return;
318 | }
319 |
320 | if (null !== $this->dropData) {
321 | $this->dropSubscription($this->dropData->reason(), $this->dropData->error());
322 |
323 | return;
324 | }
325 |
326 | try {
327 | ($this->eventAppeared)($this, $e->event(), $e->retryCount());
328 |
329 | if ($this->autoAck) {
330 | $this->subscription->notifyEventsProcessed([$e->originalEvent()->eventId()]);
331 | }
332 | } catch (Throwable $ex) {
333 | //TODO GFY should we autonak here?
334 |
335 | $this->dropSubscription(SubscriptionDropReason::eventHandlerException(), $ex);
336 |
337 | return;
338 | }
339 | }
340 | } while (! $this->queue->isEmpty() && $this->isProcessing);
341 |
342 | $this->isProcessing = false;
343 | }
344 |
345 | private function dropSubscription(SubscriptionDropReason $reason, ?Throwable $error): void
346 | {
347 | if (! $this->isDropped) {
348 | $this->isDropped = true;
349 |
350 | if (null !== $this->subscription) {
351 | $this->subscription->unsubscribe();
352 | }
353 |
354 | if ($this->subscriptionDropped) {
355 | ($this->subscriptionDropped)($this, $reason, $error);
356 | }
357 |
358 | $this->stopped = true;
359 | }
360 | }
361 | }
362 |
--------------------------------------------------------------------------------
/src/Internal/EventStoreStreamCatchUpSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\CatchUpSubscriptionSettings;
18 | use Prooph\EventStore\EventStoreConnection;
19 | use Prooph\EventStore\EventStoreStreamCatchUpSubscription as EventStoreStreamCatchUpSubscriptionInterface;
20 | use Prooph\EventStore\Exception\OutOfRangeException;
21 | use Prooph\EventStore\Exception\RuntimeException;
22 | use Prooph\EventStore\Exception\StreamDeleted;
23 | use Prooph\EventStore\ResolvedEvent;
24 | use Prooph\EventStore\SliceReadStatus;
25 | use Prooph\EventStore\StreamEventsSlice;
26 | use Prooph\EventStore\SubscriptionDropReason;
27 | use Prooph\EventStore\UserCredentials;
28 | use Psr\Log\LoggerInterface;
29 | use Throwable;
30 |
31 | class EventStoreStreamCatchUpSubscription extends EventStoreCatchUpSubscription implements EventStoreStreamCatchUpSubscriptionInterface
32 | {
33 | private int $nextReadEventNumber;
34 | private int $lastProcessedEventNumber;
35 |
36 | /**
37 | * @internal
38 | *
39 | * @param Closure(EventStoreCatchUpSubscription, ResolvedEvent): void $eventAppeared
40 | * @param null|Closure(EventStoreCatchUpSubscription): void $liveProcessingStarted
41 | * @param null|Closure(EventStoreCatchUpSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
42 | */
43 | public function __construct(
44 | EventStoreConnection $connection,
45 | LoggerInterface $logger,
46 | string $streamId,
47 | ?int $fromEventNumberExclusive, // if null from the very beginning
48 | ?UserCredentials $userCredentials,
49 | Closure $eventAppeared,
50 | ?Closure $liveProcessingStarted,
51 | ?Closure $subscriptionDropped,
52 | CatchUpSubscriptionSettings $settings
53 | ) {
54 | parent::__construct(
55 | $connection,
56 | $logger,
57 | $streamId,
58 | $userCredentials,
59 | $eventAppeared,
60 | $liveProcessingStarted,
61 | $subscriptionDropped,
62 | $settings
63 | );
64 |
65 | $this->lastProcessedEventNumber = $fromEventNumberExclusive ?? -1;
66 | $this->nextReadEventNumber = $fromEventNumberExclusive ?? 0;
67 | }
68 |
69 | public function lastProcessedEventNumber(): int
70 | {
71 | return $this->lastProcessedEventNumber;
72 | }
73 |
74 | protected function readEventsTill(
75 | EventStoreConnection $connection,
76 | bool $resolveLinkTos,
77 | ?UserCredentials $userCredentials,
78 | ?int $lastCommitPosition,
79 | ?int $lastEventNumber
80 | ): void {
81 | $this->readEventsInternal($connection, $resolveLinkTos, $userCredentials, $lastEventNumber);
82 | }
83 |
84 | private function readEventsInternal(
85 | EventStoreConnection $connection,
86 | bool $resolveLinkTos,
87 | ?UserCredentials $userCredentials,
88 | ?int $lastEventNumber
89 | ): void {
90 | do {
91 | $slice = $connection->readStreamEventsForward(
92 | $this->streamId(),
93 | $this->nextReadEventNumber,
94 | $this->readBatchSize,
95 | $resolveLinkTos,
96 | $userCredentials
97 | );
98 |
99 | $shouldStopOrDone = $this->readEventsCallback($slice, $lastEventNumber);
100 | } while (! $shouldStopOrDone);
101 | }
102 |
103 | private function readEventsCallback(StreamEventsSlice $slice, ?int $lastEventNumber): bool
104 | {
105 | $shouldStopOrDone = $this->shouldStop || $this->processEvents($lastEventNumber, $slice);
106 |
107 | if ($shouldStopOrDone && $this->verbose) {
108 | $this->log->debug(\sprintf(
109 | 'Catch-up Subscription %s to %s: finished reading events, nextReadEventNumber = %d',
110 | $this->subscriptionName(),
111 | $this->isSubscribedToAll() ? '' : $this->streamId(),
112 | $this->nextReadEventNumber
113 | ));
114 | }
115 |
116 | return $shouldStopOrDone;
117 | }
118 |
119 | private function processEvents(?int $lastEventNumber, StreamEventsSlice $slice): bool
120 | {
121 | switch ($slice->status()->value()) {
122 | case SliceReadStatus::SUCCESS:
123 | foreach ($slice->events() as $e) {
124 | $this->tryProcess($e);
125 | }
126 | $this->nextReadEventNumber = $slice->nextEventNumber();
127 | $done = (null === $lastEventNumber) ? $slice->isEndOfStream() : $slice->nextEventNumber() > $lastEventNumber;
128 |
129 | break;
130 | case SliceReadStatus::STREAM_NOT_FOUND:
131 | if (null !== $lastEventNumber && $lastEventNumber !== -1) {
132 | throw new RuntimeException(\sprintf(
133 | 'Impossible: stream %s disappeared in the middle of catching up subscription %s',
134 | $this->streamId(),
135 | $this->subscriptionName()
136 | ));
137 | }
138 |
139 | $done = true;
140 |
141 | break;
142 | case SliceReadStatus::STREAM_DELETED:
143 | throw StreamDeleted::with($this->streamId());
144 | default:
145 | throw new OutOfRangeException(\sprintf(
146 | 'Unexpected SliceReadStatus "%s" received',
147 | $slice->status()->name()
148 | ));
149 | }
150 |
151 | if (! $done && $slice->isEndOfStream()) {
152 | // we are waiting for server to flush its data
153 | \sleep(1);
154 | }
155 |
156 | return $done;
157 | }
158 |
159 | protected function tryProcess(ResolvedEvent $e): void
160 | {
161 | $processed = false;
162 |
163 | if ($e->originalEventNumber() > $this->lastProcessedEventNumber) {
164 | try {
165 | ($this->eventAppeared)($this, $e);
166 | } catch (Throwable $ex) {
167 | $this->dropSubscription(SubscriptionDropReason::eventHandlerException(), $ex);
168 | }
169 |
170 | $this->lastProcessedEventNumber = $e->originalEventNumber();
171 | $processed = true;
172 | }
173 |
174 | if ($this->verbose) {
175 | /** @psalm-suppress PossiblyNullReference */
176 | $this->log->debug(\sprintf(
177 | 'Catch-up Subscription %s to %s: %s event (%s, %d, %s @ %d)',
178 | $this->subscriptionName(),
179 | $this->isSubscribedToAll() ? '' : $this->streamId(),
180 | $processed ? 'processed' : 'skipping',
181 | $e->originalEvent()->eventStreamId(),
182 | $e->originalEvent()->eventNumber(),
183 | $e->originalEvent()->eventType(),
184 | $e->originalEventNumber()
185 | ));
186 | }
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/Internal/PersistentEventStoreSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Prooph\EventStore\Internal\PersistentEventStoreSubscription as PersistentEventStoreSubscriptionBase;
17 |
18 | class PersistentEventStoreSubscription extends PersistentEventStoreSubscriptionBase
19 | {
20 | private ConnectToPersistentSubscriptionOperation $operation;
21 |
22 | public function __construct(
23 | ConnectToPersistentSubscriptionOperation $subscriptionOperation,
24 | string $streamId
25 | ) {
26 | parent::__construct(
27 | $subscriptionOperation,
28 | $streamId,
29 | -1,
30 | -1
31 | );
32 |
33 | $this->operation = $subscriptionOperation;
34 | }
35 |
36 | public function operation(): ConnectToPersistentSubscriptionOperation
37 | {
38 | return $this->operation;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Internal/ResolvedEventParser.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Prooph\EventStore\EventId;
17 | use Prooph\EventStore\Internal\DateTimeStringBugWorkaround;
18 | use Prooph\EventStore\RecordedEvent;
19 | use Prooph\EventStore\ResolvedEvent;
20 | use Prooph\EventStore\Util\DateTime;
21 | use Prooph\EventStore\Util\Json;
22 |
23 | /** @internal */
24 | class ResolvedEventParser
25 | {
26 | public static function parse(array $entry): ResolvedEvent
27 | {
28 | $record = null;
29 |
30 | if (isset($entry['isLinkMetaData']) && $entry['isLinkMetaData']) {
31 | $data = $entry['data'];
32 |
33 | if (\is_array($data)) {
34 | $data = Json::encode($data);
35 | }
36 |
37 | $metadata = $entry['metaData'] ?? '';
38 |
39 | if (\is_array($metadata)) {
40 | $metadata = Json::encode($metadata);
41 | }
42 |
43 | $record = new RecordedEvent(
44 | $entry['streamId'],
45 | $entry['eventNumber'],
46 | EventId::fromString($entry['eventId']),
47 | $entry['eventType'],
48 | $entry['isJson'],
49 | $data,
50 | $metadata,
51 | DateTime::create(DateTimeStringBugWorkaround::fixDateTimeString(
52 | $entry['updated']
53 | ))
54 | );
55 | }
56 |
57 | $data = $record ? $entry['title'] : $entry['data'] ?? $entry['title'];
58 |
59 | if (\is_array($data)) {
60 | $data = Json::encode($data);
61 | }
62 |
63 | $metadata = $record
64 | ? $entry['linkMetaData']
65 | : $entry['metaData'] ?? '';
66 |
67 | if (\is_array($metadata)) {
68 | $metadata = Json::encode($metadata);
69 | }
70 |
71 | $eventId = $entry['eventId'] ?? null;
72 |
73 | if ($record) {
74 | foreach ($entry['links'] as $elink) {
75 | if ('ack' === $elink['relation']) {
76 | $eventId = \substr($elink['uri'], -36);
77 | break;
78 | }
79 | }
80 | }
81 |
82 | if (! isset($entry['positionEventNumber'])
83 | || ! isset($entry['positionStreamId'])
84 | ) {
85 | $matches = [];
86 | \preg_match('/^(\d+)@(.+)$/', $entry['title'], $matches);
87 | $entry['positionEventNumber'] = (int) $matches[1];
88 | $entry['positionStreamId'] = $matches[2];
89 | }
90 |
91 | $link = new RecordedEvent(
92 | $entry['positionStreamId'],
93 | $entry['positionEventNumber'],
94 | EventId::fromString($eventId ?? '00000000-0000-0000-0000-000000000000'),
95 | $entry['summary'],
96 | $entry['isJson'] ?? false,
97 | $data,
98 | $metadata,
99 | DateTime::create(DateTimeStringBugWorkaround::fixDateTimeString(
100 | $entry['updated']
101 | ))
102 | );
103 |
104 | if (null === $record && null !== $link) {
105 | $record = $link;
106 | $link = null;
107 | }
108 |
109 | return new ResolvedEvent($record ?? $link, $link, null);
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/Internal/VolatileEventStoreAllSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\EventStoreSubscription;
18 | use Prooph\EventStore\Exception\AccessDenied;
19 | use Prooph\EventStore\Exception\ObjectDisposed;
20 | use Prooph\EventStore\Internal\Consts;
21 | use Prooph\EventStore\Position;
22 | use Prooph\EventStore\SubscriptionDropReason;
23 | use Prooph\EventStore\UserCredentials;
24 | use Throwable;
25 |
26 | class VolatileEventStoreAllSubscription extends EventStoreSubscription
27 | {
28 | private EventStoreHttpConnection $connection;
29 | /** @var Closure(EventStoreSubscription, ResolvedEvent): void */
30 | private Closure $eventAppeared;
31 | /** @var Closure(EventStoreSubscription, SubscriptionDropReason, null|Throwable): void */
32 | private Closure $subscriptionDropped;
33 | private ?UserCredentials $userCredentials = null;
34 | private bool $resolveLinkTos;
35 | private Position $nextPosition;
36 | private bool $running = false;
37 | private bool $disposed = false;
38 |
39 | /**
40 | * @internal
41 | *
42 | * @param Closure(EventStoreSubscription, ResolvedEvent): void $eventAppeared
43 | * @param null|Closure(EventStoreSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
44 | */
45 | public function __construct(
46 | EventStoreHttpConnection $connection,
47 | Closure $eventAppeared,
48 | ?Closure $subscriptionDropped,
49 | string $streamId,
50 | Position $nextPosition,
51 | bool $resolveLinkTos,
52 | ?UserCredentials $userCredentials
53 | ) {
54 | parent::__construct($streamId, $nextPosition->commitPosition(), $nextPosition->preparePosition());
55 |
56 | $this->connection = $connection;
57 | $this->eventAppeared = $eventAppeared;
58 | $this->subscriptionDropped = $subscriptionDropped;
59 | $this->userCredentials = $userCredentials;
60 | $this->resolveLinkTos = $resolveLinkTos;
61 | $this->nextPosition = $nextPosition;
62 | }
63 |
64 | public function start(): void
65 | {
66 | if ($this->disposed) {
67 | throw new ObjectDisposed('This volatile subscription was already stopped');
68 | }
69 |
70 | $this->running = true;
71 |
72 | $nextPosition = $this->nextPosition;
73 |
74 | while ($this->running) {
75 | try {
76 | $allEventsSlice = $this->connection->readAllEventsForwardPolling(
77 | $nextPosition,
78 | Consts::CATCH_UP_DEFAULT_READ_BATCH_SIZE,
79 | $this->resolveLinkTos,
80 | 1,
81 | $this->userCredentials
82 | );
83 | } catch (AccessDenied $e) {
84 | if ($this->subscriptionDropped) {
85 | ($this->subscriptionDropped)(
86 | $this,
87 | SubscriptionDropReason::accessDenied(),
88 | $e
89 | );
90 | }
91 |
92 | $this->unsubscribe();
93 |
94 | return;
95 | } catch (Throwable $e) {
96 | if ($this->subscriptionDropped) {
97 | ($this->subscriptionDropped)(
98 | $this,
99 | SubscriptionDropReason::serverError(),
100 | $e
101 | );
102 | }
103 |
104 | $this->unsubscribe();
105 |
106 | return;
107 | }
108 |
109 | foreach ($allEventsSlice->events() as $event) {
110 | try {
111 | ($this->eventAppeared)($this, $event);
112 | } catch (Throwable $e) {
113 | if ($this->subscriptionDropped) {
114 | ($this->subscriptionDropped)(
115 | $this,
116 | SubscriptionDropReason::eventHandlerException(),
117 | $e
118 | );
119 |
120 | $this->unsubscribe();
121 |
122 | return;
123 | }
124 | }
125 | }
126 |
127 | $nextPosition = $allEventsSlice->nextPosition();
128 | }
129 | }
130 |
131 | public function unsubscribe(): void
132 | {
133 | $this->running = false;
134 | $this->disposed = true;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/Internal/VolatileEventStoreStreamSubscription.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Internal;
15 |
16 | use Closure;
17 | use Prooph\EventStore\EventStoreSubscription;
18 | use Prooph\EventStore\Exception\AccessDenied;
19 | use Prooph\EventStore\Exception\ObjectDisposed;
20 | use Prooph\EventStore\Internal\Consts;
21 | use Prooph\EventStore\SliceReadStatus;
22 | use Prooph\EventStore\SubscriptionDropReason;
23 | use Prooph\EventStore\UserCredentials;
24 | use Throwable;
25 |
26 | class VolatileEventStoreStreamSubscription extends EventStoreSubscription
27 | {
28 | private EventStoreHttpConnection $connection;
29 | /** @var Closure(EventStoreSubscription, ResolvedEvent): void */
30 | private Closure $eventAppeared;
31 | /** @var null|Closure(EventStoreSubscription, SubscriptionDropReason, null|Throwable): void */
32 | private ?Closure $subscriptionDropped;
33 | private ?UserCredentials $userCredentials = null;
34 | private bool $resolveLinkTos;
35 | private bool $running = false;
36 | private bool $disposed = false;
37 |
38 | /**
39 | * @internal
40 | *
41 | * @param Closure(EventStoreSubscription, ResolvedEvent): void $eventAppeared
42 | * @param null|Closure(EventStoreSubscription, SubscriptionDropReason, null|Throwable): void $subscriptionDropped
43 | */
44 | public function __construct(
45 | EventStoreHttpConnection $connection,
46 | Closure $eventAppeared,
47 | ?Closure $subscriptionDropped,
48 | string $streamId,
49 | ?int $lastEventNumber,
50 | bool $resolveLinkTos,
51 | ?UserCredentials $userCredentials
52 | ) {
53 | parent::__construct($streamId, -1, $lastEventNumber);
54 |
55 | $this->connection = $connection;
56 | $this->eventAppeared = $eventAppeared;
57 | $this->subscriptionDropped = $subscriptionDropped;
58 | $this->userCredentials = $userCredentials;
59 | $this->resolveLinkTos = $resolveLinkTos;
60 | }
61 |
62 | public function start(): void
63 | {
64 | if ($this->disposed) {
65 | throw new ObjectDisposed('This volatile subscription was already stopped');
66 | }
67 |
68 | $this->running = true;
69 |
70 | $lastEventNumber = $this->lastEventNumber();
71 | $stream = $this->streamId();
72 |
73 | while ($this->running) {
74 | try {
75 | $streamEventsSlice = $this->connection->readStreamEventsForwardPolling(
76 | $stream,
77 | $lastEventNumber,
78 | Consts::CATCH_UP_DEFAULT_READ_BATCH_SIZE,
79 | $this->resolveLinkTos,
80 | 1,
81 | $this->userCredentials
82 | );
83 | } catch (AccessDenied $e) {
84 | if ($this->subscriptionDropped) {
85 | ($this->subscriptionDropped)(
86 | $this,
87 | SubscriptionDropReason::accessDenied(),
88 | $e
89 | );
90 | }
91 |
92 | $this->unsubscribe();
93 |
94 | return;
95 | } catch (Throwable $e) {
96 | if ($this->subscriptionDropped) {
97 | ($this->subscriptionDropped)(
98 | $this,
99 | SubscriptionDropReason::serverError(),
100 | $e
101 | );
102 | }
103 |
104 | $this->unsubscribe();
105 |
106 | return;
107 | }
108 |
109 | if ($streamEventsSlice->status()->equals(SliceReadStatus::streamDeleted())) {
110 | if ($this->subscriptionDropped) {
111 | ($this->subscriptionDropped)(
112 | $this,
113 | SubscriptionDropReason::subscribingError()
114 | );
115 | }
116 |
117 | $this->unsubscribe();
118 |
119 | return;
120 | }
121 |
122 | foreach ($streamEventsSlice->events() as $event) {
123 | try {
124 | ($this->eventAppeared)($this, $event);
125 | } catch (Throwable $e) {
126 | if ($this->subscriptionDropped) {
127 | ($this->subscriptionDropped)(
128 | $this,
129 | SubscriptionDropReason::eventHandlerException(),
130 | $e
131 | );
132 |
133 | $this->unsubscribe();
134 |
135 | return;
136 | }
137 | }
138 | }
139 |
140 | $lastEventNumber = $streamEventsSlice->nextEventNumber();
141 | }
142 | }
143 |
144 | public function unsubscribe(): void
145 | {
146 | $this->running = false;
147 | $this->disposed = true;
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/PersistentSubscriptions/PersistentSubscriptionsManager.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\PersistentSubscriptions;
15 |
16 | use Http\Message\RequestFactory;
17 | use Prooph\EventStore\Exception\EventStoreConnectionException;
18 | use Prooph\EventStore\Exception\InvalidArgumentException;
19 | use Prooph\EventStore\PersistentSubscriptions\PersistentSubscriptionDetails;
20 | use Prooph\EventStore\PersistentSubscriptions\PersistentSubscriptionsManager as SyncPersistentSubscriptionsManager;
21 | use Prooph\EventStore\Transport\Http\HttpStatusCode;
22 | use Prooph\EventStore\UserCredentials;
23 | use Prooph\EventStore\Util\Json;
24 | use Prooph\EventStoreHttpClient\ConnectionSettings;
25 | use Prooph\EventStoreHttpClient\Exception\PersistentSubscriptionCommandFailed;
26 | use Prooph\EventStoreHttpClient\Http\HttpClient;
27 | use Psr\Http\Client\ClientInterface;
28 | use Throwable;
29 |
30 | class PersistentSubscriptionsManager implements SyncPersistentSubscriptionsManager
31 | {
32 | private ConnectionSettings $settings;
33 | private HttpClient $httpClient;
34 |
35 | /** @internal */
36 | public function __construct(
37 | ConnectionSettings $settings,
38 | ClientInterface $client,
39 | RequestFactory $requestFactory
40 | ) {
41 | $this->settings = $settings;
42 |
43 | $this->httpClient = new HttpClient(
44 | $client,
45 | $requestFactory,
46 | $settings,
47 | \sprintf(
48 | '%s://%s:%s',
49 | $settings->schema(),
50 | $settings->endPoint()->host(),
51 | $settings->endPoint()->port()
52 | )
53 | );
54 | }
55 |
56 | public function describe(
57 | string $stream,
58 | string $subscriptionName,
59 | ?UserCredentials $userCredentials = null
60 | ): PersistentSubscriptionDetails {
61 | if (empty($stream)) {
62 | throw new InvalidArgumentException('Stream cannot be empty');
63 | }
64 |
65 | if (empty($subscriptionName)) {
66 | throw new InvalidArgumentException('Subscription name cannot be empty');
67 | }
68 |
69 | $body = $this->sendGet(
70 | \sprintf(
71 | '/subscriptions/%s/%s/info',
72 | \urlencode($stream),
73 | \urlencode($subscriptionName)
74 | ),
75 | $userCredentials,
76 | HttpStatusCode::OK
77 | );
78 |
79 | return PersistentSubscriptionDetails::fromArray(Json::decode($body));
80 | }
81 |
82 | public function replayParkedMessages(
83 | string $stream,
84 | string $subscriptionName,
85 | ?UserCredentials $userCredentials = null
86 | ): void {
87 | if (empty($stream)) {
88 | throw new InvalidArgumentException('Stream cannot be empty');
89 | }
90 |
91 | if (empty($subscriptionName)) {
92 | throw new InvalidArgumentException('Subscription name cannot be empty');
93 | }
94 |
95 | $this->sendPost(
96 | \sprintf(
97 | '/subscriptions/%s/%s/replayParked',
98 | \urlencode($stream),
99 | \urlencode($subscriptionName)
100 | ),
101 | '',
102 | $userCredentials,
103 | HttpStatusCode::OK
104 | );
105 | }
106 |
107 | /**
108 | * @param null|string $stream
109 | * @param null|UserCredentials $userCredentials
110 | * @return PersistentSubscriptionDetails[]
111 | */
112 | public function list(?string $stream = null, ?UserCredentials $userCredentials = null): array
113 | {
114 | if ('' === $stream) {
115 | $stream = null;
116 | }
117 |
118 | $uri = '/subscriptions';
119 |
120 | if (null !== $stream) {
121 | $uri .= "/$stream";
122 | }
123 |
124 | $body = $this->sendGet(
125 | $uri,
126 | $userCredentials,
127 | HttpStatusCode::OK
128 | );
129 |
130 | $details = [];
131 |
132 | foreach (Json::decode($body) as $entry) {
133 | $details[] = PersistentSubscriptionDetails::fromArray($entry);
134 | }
135 |
136 | return $details;
137 | }
138 |
139 | private function sendGet(string $url, ?UserCredentials $userCredentials, int $expectedCode): string
140 | {
141 | $response = $this->httpClient->get(
142 | $url,
143 | [],
144 | $userCredentials,
145 | static function (Throwable $e) {
146 | throw new EventStoreConnectionException($e->getMessage());
147 | }
148 | );
149 |
150 | if ($response->getStatusCode() !== $expectedCode) {
151 | throw new PersistentSubscriptionCommandFailed(
152 | $response->getStatusCode(),
153 | \sprintf(
154 | 'Server returned %d (%s) for GET on %s',
155 | $response->getStatusCode(),
156 | $response->getReasonPhrase(),
157 | $url
158 | )
159 | );
160 | }
161 |
162 | return $response->getBody()->getContents();
163 | }
164 |
165 | private function sendPost(
166 | string $url,
167 | string $content,
168 | ?UserCredentials $userCredentials,
169 | int $expectedCode
170 | ): void {
171 | $response = $this->httpClient->post(
172 | $url,
173 | ['Content-Type' => 'application/json'],
174 | $content,
175 | $userCredentials,
176 | static function (Throwable $e) {
177 | throw new EventStoreConnectionException($e->getMessage());
178 | }
179 | );
180 |
181 | if ($response->getStatusCode() !== $expectedCode) {
182 | throw new PersistentSubscriptionCommandFailed(
183 | $response->getStatusCode(),
184 | \sprintf(
185 | 'Server returned %d (%s) for POST on %s',
186 | $response->getStatusCode(),
187 | $response->getReasonPhrase(),
188 | $url
189 | )
190 | );
191 | }
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/src/PersistentSubscriptions/PersistentSubscriptionsManagerFactory.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\PersistentSubscriptions;
15 |
16 | use Http\Discovery\HttpClientDiscovery;
17 | use Http\Discovery\MessageFactoryDiscovery;
18 | use Http\Message\RequestFactory;
19 | use Prooph\EventStore\PersistentSubscriptions\PersistentSubscriptionsManager as SyncPersistentSubscriptionsManager;
20 | use Prooph\EventStoreHttpClient\ConnectionSettings;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | class PersistentSubscriptionsManagerFactory
24 | {
25 | public static function create(
26 | ConnectionSettings $settings = null,
27 | ClientInterface $client = null,
28 | RequestFactory $requestFactory = null
29 | ): SyncPersistentSubscriptionsManager {
30 | return new PersistentSubscriptionsManager(
31 | $settings ?? ConnectionSettings::default(),
32 | $client ?? HttpClientDiscovery::find(),
33 | $requestFactory ?? MessageFactoryDiscovery::find()
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Projections/ProjectionsManager.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Projections;
15 |
16 | use Http\Message\RequestFactory;
17 | use Prooph\EventStore\Exception\EventStoreConnectionException;
18 | use Prooph\EventStore\Exception\InvalidArgumentException;
19 | use Prooph\EventStore\Projections\ProjectionDetails;
20 | use Prooph\EventStore\Projections\ProjectionsManager as SyncProjectionsManager;
21 | use Prooph\EventStore\Projections\ProjectionStatistics;
22 | use Prooph\EventStore\Projections\Query;
23 | use Prooph\EventStore\Projections\State;
24 | use Prooph\EventStore\Transport\Http\HttpStatusCode;
25 | use Prooph\EventStore\UserCredentials;
26 | use Prooph\EventStore\Util\Json;
27 | use Prooph\EventStoreHttpClient\ConnectionSettings;
28 | use Prooph\EventStoreHttpClient\Exception\ProjectionCommandConflict;
29 | use Prooph\EventStoreHttpClient\Exception\ProjectionCommandFailed;
30 | use Prooph\EventStoreHttpClient\Http\HttpClient;
31 | use Psr\Http\Client\ClientInterface;
32 | use Psr\Http\Message\ResponseInterface;
33 | use Throwable;
34 |
35 | class ProjectionsManager implements SyncProjectionsManager
36 | {
37 | private ConnectionSettings $settings;
38 | private HttpClient $httpClient;
39 |
40 | /** @internal */
41 | public function __construct(
42 | ConnectionSettings $settings,
43 | ClientInterface $client,
44 | RequestFactory $requestFactory
45 | ) {
46 | $this->settings = $settings;
47 |
48 | $this->httpClient = new HttpClient(
49 | $client,
50 | $requestFactory,
51 | $settings,
52 | \sprintf(
53 | '%s://%s:%s',
54 | $settings->schema(),
55 | $settings->endPoint()->host(),
56 | $settings->endPoint()->port()
57 | )
58 | );
59 | }
60 |
61 | /**
62 | * Enables a projection
63 | */
64 | public function enable(string $name, ?UserCredentials $userCredentials = null): void
65 | {
66 | if ('' === $name) {
67 | throw new InvalidArgumentException('Name is required');
68 | }
69 |
70 | $this->sendPost(
71 | \sprintf(
72 | '/projection/%s/command/enable',
73 | \urlencode($name)
74 | ),
75 | '',
76 | $userCredentials,
77 | HttpStatusCode::OK
78 | );
79 | }
80 |
81 | /**
82 | * Aborts and disables a projection without writing a checkpoint
83 | */
84 | public function disable(string $name, ?UserCredentials $userCredentials = null): void
85 | {
86 | if ('' === $name) {
87 | throw new InvalidArgumentException('Name is required');
88 | }
89 |
90 | $this->sendPost(
91 | \sprintf(
92 | '/projection/%s/command/disable',
93 | \urlencode($name)
94 | ),
95 | '',
96 | $userCredentials,
97 | HttpStatusCode::OK
98 | );
99 | }
100 |
101 | /**
102 | * Disables a projection
103 | */
104 | public function abort(string $name, ?UserCredentials $userCredentials = null): void
105 | {
106 | if ('' === $name) {
107 | throw new InvalidArgumentException('Name is required');
108 | }
109 |
110 | $this->sendPost(
111 | \sprintf(
112 | '/projection/%s/command/abort',
113 | \urlencode($name)
114 | ),
115 | '',
116 | $userCredentials,
117 | HttpStatusCode::OK
118 | );
119 | }
120 |
121 | /**
122 | * Creates a one-time query
123 | */
124 | public function createOneTime(
125 | string $query,
126 | string $type = 'JS',
127 | ?UserCredentials $userCredentials = null
128 | ): void {
129 | if ('' === $query) {
130 | throw new InvalidArgumentException('Query is required');
131 | }
132 |
133 | $this->sendPost(
134 | \sprintf(
135 | '/projections/onetime?type=%s',
136 | $type
137 | ),
138 | $query,
139 | $userCredentials,
140 | HttpStatusCode::CREATED
141 | );
142 | }
143 |
144 | /**
145 | * Creates a one-time query
146 | */
147 | public function createTransient(
148 | string $name,
149 | string $query,
150 | string $type = 'JS',
151 | ?UserCredentials $userCredentials = null
152 | ): void {
153 | if ('' === $name) {
154 | throw new InvalidArgumentException('Name is required');
155 | }
156 |
157 | if ('' === $query) {
158 | throw new InvalidArgumentException('Query is required');
159 | }
160 |
161 | $this->sendPost(
162 | \sprintf(
163 | '/projections/transient?name=%s&type=%s',
164 | \urlencode($name),
165 | $type
166 | ),
167 | $query,
168 | $userCredentials,
169 | HttpStatusCode::CREATED
170 | );
171 | }
172 |
173 | /**
174 | * Creates a continuous projection
175 | */
176 | public function createContinuous(
177 | string $name,
178 | string $query,
179 | bool $trackEmittedStreams = false,
180 | string $type = 'JS',
181 | ?UserCredentials $userCredentials = null
182 | ): void {
183 | if ('' === $name) {
184 | throw new InvalidArgumentException('Name is required');
185 | }
186 |
187 | if ('' === $query) {
188 | throw new InvalidArgumentException('Query is required');
189 | }
190 |
191 | $this->sendPost(
192 | \sprintf(
193 | '/projections/continuous?name=%s&type=%s&emit=1&trackemittedstreams=%d',
194 | \urlencode($name),
195 | $type,
196 | (int) $trackEmittedStreams
197 | ),
198 | $query,
199 | $userCredentials,
200 | HttpStatusCode::CREATED
201 | );
202 | }
203 |
204 | /**
205 | * Lists all projections
206 | *
207 | * @return ProjectionDetails[]
208 | */
209 | public function listAll(?UserCredentials $userCredentials = null): array
210 | {
211 | $response = $this->sendGet(
212 | '/projections/any',
213 | $userCredentials,
214 | HttpStatusCode::OK
215 | );
216 |
217 | $data = Json::decode($response->getBody()->getContents());
218 |
219 | $projectionDetails = [];
220 |
221 | if (null === $data['projections']) {
222 | return $projectionDetails;
223 | }
224 |
225 | foreach ($data['projections'] as $entry) {
226 | $projectionDetails[] = $this->buildProjectionDetails($entry);
227 | }
228 |
229 | return $projectionDetails;
230 | }
231 |
232 | /**
233 | * Lists all one-time projections
234 | *
235 | * @return ProjectionDetails[]
236 | */
237 | public function listOneTime(?UserCredentials $userCredentials = null): array
238 | {
239 | $response = $this->sendGet(
240 | '/projections/onetime',
241 | $userCredentials,
242 | HttpStatusCode::OK
243 | );
244 |
245 | $data = Json::decode($response->getBody()->getContents());
246 |
247 | $projectionDetails = [];
248 |
249 | if (null === $data['projections']) {
250 | return $projectionDetails;
251 | }
252 |
253 | foreach ($data['projections'] as $entry) {
254 | $projectionDetails[] = $this->buildProjectionDetails($entry);
255 | }
256 |
257 | return $projectionDetails;
258 | }
259 |
260 | /**
261 | * Lists this status of all continuous projections
262 | *
263 | * @return ProjectionDetails[]
264 | */
265 | public function listContinuous(?UserCredentials $userCredentials = null): array
266 | {
267 | $response = $this->sendGet(
268 | '/projections/continuous',
269 | $userCredentials,
270 | HttpStatusCode::OK
271 | );
272 |
273 | $data = Json::decode($response->getBody()->getContents());
274 |
275 | $projectionDetails = [];
276 |
277 | if (null === $data['projections']) {
278 | return $projectionDetails;
279 | }
280 |
281 | foreach ($data['projections'] as $entry) {
282 | $projectionDetails[] = $this->buildProjectionDetails($entry);
283 | }
284 |
285 | return $projectionDetails;
286 | }
287 |
288 | /**
289 | * Returns String of JSON containing projection status
290 | */
291 | public function getStatus(string $name, ?UserCredentials $userCredentials = null): ProjectionDetails
292 | {
293 | if ('' === $name) {
294 | throw new InvalidArgumentException('Name is required');
295 | }
296 |
297 | return $this->buildProjectionDetails(
298 | Json::decode(
299 | $this->sendGet(
300 | \sprintf(
301 | '/projection/%s',
302 | \urlencode($name)
303 | ),
304 | $userCredentials,
305 | HttpStatusCode::OK
306 | )->getBody()->getContents()
307 | )
308 | );
309 | }
310 |
311 | /**
312 | * Returns String of JSON containing projection state
313 | */
314 | public function getState(string $name, ?UserCredentials $userCredentials = null): State
315 | {
316 | if ('' === $name) {
317 | throw new InvalidArgumentException('Name is required');
318 | }
319 |
320 | return new State(
321 | $this->sendGet(
322 | \sprintf(
323 | '/projection/%s/state',
324 | \urlencode($name)
325 | ),
326 | $userCredentials,
327 | HttpStatusCode::OK
328 | )->getBody()->getContents()
329 | );
330 | }
331 |
332 | /**
333 | * Returns String of JSON containing projection state
334 | */
335 | public function getPartitionState(
336 | string $name,
337 | string $partition,
338 | ?UserCredentials $userCredentials = null
339 | ): State {
340 | if ('' === $name) {
341 | throw new InvalidArgumentException('Name is required');
342 | }
343 |
344 | if ('' === $partition) {
345 | throw new InvalidArgumentException('Partition is required');
346 | }
347 |
348 | return new State(
349 | $this->sendGet(
350 | \sprintf(
351 | '/projection/%s/state?partition=%s',
352 | \urlencode($name),
353 | \urlencode($partition)
354 | ),
355 | $userCredentials,
356 | HttpStatusCode::OK
357 | )->getBody()->getContents()
358 | );
359 | }
360 |
361 | public function getResult(string $name, ?UserCredentials $userCredentials = null): State
362 | {
363 | if ('' === $name) {
364 | throw new InvalidArgumentException('Name is required');
365 | }
366 |
367 | return new State(
368 | $this->sendGet(
369 | \sprintf(
370 | '/projection/%s/result',
371 | \urlencode($name)
372 | ),
373 | $userCredentials,
374 | HttpStatusCode::OK
375 | )->getBody()->getContents()
376 | );
377 | }
378 |
379 | public function getPartitionResult(
380 | string $name,
381 | string $partition,
382 | ?UserCredentials $userCredentials = null
383 | ): State {
384 | if ('' === $name) {
385 | throw new InvalidArgumentException('Name is required');
386 | }
387 |
388 | if ('' === $partition) {
389 | throw new InvalidArgumentException('Partition is required');
390 | }
391 |
392 | return new State(
393 | $this->sendGet(
394 | \sprintf(
395 | '/projection/%s/result?partition=%s',
396 | \urlencode($name),
397 | \urlencode($partition)
398 | ),
399 | $userCredentials,
400 | HttpStatusCode::OK
401 | )->getBody()->getContents()
402 | );
403 | }
404 |
405 | public function getStatistics(string $name, ?UserCredentials $userCredentials = null): ProjectionStatistics
406 | {
407 | if ('' === $name) {
408 | throw new InvalidArgumentException('Name is required');
409 | }
410 |
411 | return new ProjectionStatistics(
412 | \json_decode(
413 | $this->sendGet(
414 | \sprintf(
415 | '/projection/%s/statistics',
416 | \urlencode($name)
417 | ),
418 | $userCredentials,
419 | HttpStatusCode::OK
420 | )->getBody()->getContents()
421 | )
422 | );
423 | }
424 |
425 | public function getQuery(string $name, ?UserCredentials $userCredentials = null): Query
426 | {
427 | if ('' === $name) {
428 | throw new InvalidArgumentException('Name is required');
429 | }
430 |
431 | return new Query(
432 | $this->sendGet(
433 | \sprintf(
434 | '/projection/%s/query',
435 | \urlencode($name)
436 | ),
437 | $userCredentials,
438 | HttpStatusCode::OK
439 | )->getBody()->getContents()
440 | );
441 | }
442 |
443 | public function updateQuery(
444 | string $name,
445 | string $query,
446 | ?bool $emitEnabled = false,
447 | ?UserCredentials $userCredentials = null
448 | ): void {
449 | if ('' === $name) {
450 | throw new InvalidArgumentException('Name is required');
451 | }
452 |
453 | if ('' === $query) {
454 | throw new InvalidArgumentException('Query is required');
455 | }
456 |
457 | $emit = '';
458 |
459 | if (null !== $emitEnabled) {
460 | $emit = '?emit=' . (string) (int) $emitEnabled;
461 | }
462 |
463 | $this->sendPut(
464 | \sprintf(
465 | '/projection/%s/query' . $emit,
466 | \urlencode($name)
467 | ),
468 | $query,
469 | $userCredentials,
470 | HttpStatusCode::OK
471 | );
472 | }
473 |
474 | public function delete(
475 | string $name,
476 | bool $deleteEmittedStreams = false,
477 | ?UserCredentials $userCredentials = null
478 | ): void {
479 | if ('' === $name) {
480 | throw new InvalidArgumentException('Name is required');
481 | }
482 |
483 | $this->sendDelete(
484 | \sprintf(
485 | '/projection/%s?deleteEmittedStreams=%d',
486 | \urlencode($name),
487 | (int) $deleteEmittedStreams
488 | ),
489 | $userCredentials,
490 | HttpStatusCode::OK
491 | );
492 | }
493 |
494 | public function reset(string $name, ?UserCredentials $userCredentials = null): void
495 | {
496 | if ('' === $name) {
497 | throw new InvalidArgumentException('Name is required');
498 | }
499 |
500 | $this->sendPost(
501 | \sprintf(
502 | '/projection/%s/command/reset',
503 | \urlencode($name)
504 | ),
505 | '',
506 | $userCredentials,
507 | HttpStatusCode::OK
508 | );
509 | }
510 |
511 | private function buildProjectionDetails(array $entry): ProjectionDetails
512 | {
513 | return new ProjectionDetails(
514 | $entry['coreProcessingTime'],
515 | $entry['version'],
516 | $entry['epoch'],
517 | $entry['effectiveName'],
518 | $entry['writesInProgress'],
519 | $entry['readsInProgress'],
520 | $entry['partitionsCached'],
521 | $entry['status'],
522 | $entry['stateReason'] ?? '',
523 | $entry['name'],
524 | $entry['mode'],
525 | $entry['position'],
526 | $entry['progress'],
527 | $entry['lastCheckpoint'] ?? '',
528 | $entry['eventsProcessedAfterRestart'],
529 | $entry['statusUrl'],
530 | $entry['stateUrl'],
531 | $entry['resultUrl'],
532 | $entry['queryUrl'],
533 | $entry['enableCommandUrl'],
534 | $entry['disableCommandUrl'],
535 | $entry['checkpointStatus'] ?? '',
536 | $entry['bufferedEvents'],
537 | $entry['writePendingEventsBeforeCheckpoint'],
538 | $entry['writePendingEventsAfterCheckpoint']
539 | );
540 | }
541 |
542 | private function sendGet(
543 | string $uri,
544 | ?UserCredentials $userCredentials,
545 | int $expectedCode
546 | ): ResponseInterface {
547 | $response = $this->httpClient->get(
548 | $uri,
549 | [],
550 | $userCredentials,
551 | static function (Throwable $e) {
552 | throw new EventStoreConnectionException($e->getMessage());
553 | }
554 | );
555 |
556 | if ($response->getStatusCode() !== $expectedCode) {
557 | throw new ProjectionCommandFailed(
558 | $response->getStatusCode(),
559 | \sprintf(
560 | 'Server returned %d (%s) for GET on %s',
561 | $response->getStatusCode(),
562 | $response->getReasonPhrase(),
563 | $uri
564 | )
565 | );
566 | }
567 |
568 | return $response;
569 | }
570 |
571 | private function sendDelete(
572 | string $uri,
573 | ?UserCredentials $userCredentials,
574 | int $expectedCode
575 | ): void {
576 | $response = $this->httpClient->delete(
577 | $uri,
578 | [],
579 | $userCredentials,
580 | static function (Throwable $e) {
581 | throw new EventStoreConnectionException($e->getMessage());
582 | }
583 | );
584 |
585 | if ($response->getStatusCode() !== $expectedCode) {
586 | throw new ProjectionCommandFailed(
587 | $response->getStatusCode(),
588 | \sprintf(
589 | 'Server returned %d (%s) for DELETE on %s',
590 | $response->getStatusCode(),
591 | $response->getReasonPhrase(),
592 | $uri
593 | )
594 | );
595 | }
596 | }
597 |
598 | private function sendPut(
599 | string $uri,
600 | string $content,
601 | ?UserCredentials $userCredentials,
602 | int $expectedCode
603 | ): void {
604 | $response = $this->httpClient->put(
605 | $uri,
606 | ['Content-Type' => 'application/json'],
607 | $content,
608 | $userCredentials,
609 | static function (Throwable $e) {
610 | throw new EventStoreConnectionException($e->getMessage());
611 | }
612 | );
613 |
614 | if ($response->getStatusCode() !== $expectedCode) {
615 | throw new ProjectionCommandFailed(
616 | $response->getStatusCode(),
617 | \sprintf(
618 | 'Server returned %d (%s) for PUT on %s',
619 | $response->getStatusCode(),
620 | $response->getReasonPhrase(),
621 | $uri
622 | )
623 | );
624 | }
625 | }
626 |
627 | private function sendPost(
628 | string $uri,
629 | string $content,
630 | ?UserCredentials $userCredentials,
631 | int $expectedCode
632 | ): void {
633 | $response = $this->httpClient->post(
634 | $uri,
635 | ['Content-Type' => 'application/json'],
636 | $content,
637 | $userCredentials,
638 | static function (Throwable $e) {
639 | throw new EventStoreConnectionException($e->getMessage());
640 | }
641 | );
642 |
643 | if ($response->getStatusCode() === HttpStatusCode::CONFLICT) {
644 | throw new ProjectionCommandConflict($response->getStatusCode(), $response->getReasonPhrase());
645 | }
646 |
647 | if ($response->getStatusCode() !== $expectedCode) {
648 | throw new ProjectionCommandFailed(
649 | $response->getStatusCode(),
650 | \sprintf(
651 | 'Server returned %d (%s) for POST on %s',
652 | $response->getStatusCode(),
653 | $response->getReasonPhrase(),
654 | $uri
655 | )
656 | );
657 | }
658 | }
659 | }
660 |
--------------------------------------------------------------------------------
/src/Projections/ProjectionsManagerFactory.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Projections;
15 |
16 | use Http\Discovery\HttpClientDiscovery;
17 | use Http\Discovery\MessageFactoryDiscovery;
18 | use Http\Message\RequestFactory;
19 | use Prooph\EventStore\Projections\ProjectionsManager as SyncProjectionsManager;
20 | use Prooph\EventStoreHttpClient\ConnectionSettings;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | class ProjectionsManagerFactory
24 | {
25 | public static function create(
26 | ConnectionSettings $settings = null,
27 | ClientInterface $client = null,
28 | RequestFactory $requestFactory = null
29 | ): SyncProjectionsManager {
30 | return new ProjectionsManager(
31 | $settings ?? ConnectionSettings::default(),
32 | $client ?? HttpClientDiscovery::find(),
33 | $requestFactory ?? MessageFactoryDiscovery::find()
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Projections/QueryManager.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Projections;
15 |
16 | use Http\Message\RequestFactory;
17 | use Prooph\EventStore\Projections\QueryManager as SyncQueryManager;
18 | use Prooph\EventStore\Projections\State;
19 | use Prooph\EventStore\UserCredentials;
20 | use Prooph\EventStoreHttpClient\ConnectionSettings;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | /**
24 | * API for executing queries in the Event Store through PHP code.
25 | * Communicates with the Event Store over the RESTful API.
26 | *
27 | * Note: Configure the HTTP client with large enough timeout.
28 | */
29 | class QueryManager implements SyncQueryManager
30 | {
31 | private ProjectionsManager $projectionsManager;
32 |
33 | /** @internal */
34 | public function __construct(
35 | ConnectionSettings $settings,
36 | ClientInterface $client,
37 | RequestFactory $requestFactory
38 | ) {
39 | $this->projectionsManager = new ProjectionsManager(
40 | $settings,
41 | $client,
42 | $requestFactory
43 | );
44 | }
45 |
46 | /**
47 | * Executes a query
48 | *
49 | * Creates a new transient projection and polls its status until it is Completed
50 | */
51 | public function execute(
52 | string $name,
53 | string $query,
54 | int $initialPollingDelay,
55 | int $maximumPollingDelay,
56 | string $type = 'JS',
57 | ?UserCredentials $userCredentials = null
58 | ): State {
59 | $this->projectionsManager->createTransient(
60 | $name,
61 | $query,
62 | $type,
63 | $userCredentials
64 | );
65 |
66 | $this->waitForCompleted(
67 | $name,
68 | $initialPollingDelay,
69 | $maximumPollingDelay,
70 | $userCredentials
71 | );
72 |
73 | return $this->projectionsManager->getState(
74 | $name,
75 | $userCredentials
76 | );
77 | }
78 |
79 | private function waitForCompleted(
80 | string $name,
81 | int $initialPollingDelay,
82 | int $maximumPollingDelay,
83 | ?UserCredentials $userCredentials
84 | ): void {
85 | $attempts = 0;
86 | $status = $this->getStatus($name, $userCredentials);
87 |
88 | while (false === \strpos($status, 'Completed')) {
89 | $attempts++;
90 |
91 | $this->delayPolling(
92 | $attempts,
93 | $initialPollingDelay,
94 | $maximumPollingDelay
95 | );
96 |
97 | $status = $this->getStatus($name, $userCredentials);
98 | }
99 | }
100 |
101 | private function delayPolling(
102 | int $attempts,
103 | int $initialPollingDelay,
104 | int $maximumPollingDelay
105 | ): void {
106 | $delayInMilliseconds = $initialPollingDelay * (2 ** $attempts - 1);
107 | $delayInMilliseconds = (int) \min($delayInMilliseconds, $maximumPollingDelay);
108 |
109 | \usleep($delayInMilliseconds * 1000);
110 | }
111 |
112 | private function getStatus(string $name, ?UserCredentials $userCredentials): string
113 | {
114 | return $this->projectionsManager->getStatus($name, $userCredentials);
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Projections/QueryManagerFactory.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\Projections;
15 |
16 | use Http\Discovery\HttpClientDiscovery;
17 | use Http\Discovery\MessageFactoryDiscovery;
18 | use Http\Message\RequestFactory;
19 | use Prooph\EventStore\Projections\QueryManager as SyncQueryManager;
20 | use Prooph\EventStoreHttpClient\ConnectionSettings;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | class QueryManagerFactory
24 | {
25 | public static function create(
26 | ConnectionSettings $settings = null,
27 | ClientInterface $client = null,
28 | RequestFactory $requestFactory = null
29 | ): SyncQueryManager {
30 | return new QueryManager(
31 | $settings ?? ConnectionSettings::default(),
32 | $client ?? HttpClientDiscovery::find(),
33 | $requestFactory ?? MessageFactoryDiscovery::find()
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Uri.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient;
15 |
16 | use Prooph\EventStore\Exception\InvalidArgumentException;
17 | use Prooph\EventStore\UserCredentials;
18 |
19 | class Uri
20 | {
21 | /**
22 | * Sub-delimiters used in user info, query strings and fragments.
23 | * @const string
24 | */
25 | private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
26 |
27 | /**
28 | * Unreserved characters used in user info, paths, query strings, and fragments.
29 | * @const string
30 | */
31 | private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~\pL';
32 |
33 | private const TCP_PORT_DEFAULT = 1113;
34 |
35 | private string $scheme;
36 | private ?UserCredentials $userCredentials;
37 | private string $host;
38 | private int $port;
39 |
40 | public function __construct(
41 | string $scheme,
42 | string $host,
43 | int $port,
44 | ?UserCredentials $userCredentials = null
45 | ) {
46 | $this->scheme = $scheme;
47 | $this->host = $host;
48 | $this->port = $port;
49 | $this->userCredentials = $userCredentials;
50 | }
51 |
52 | public static function fromString(string $uri): self
53 | {
54 | $parts = \parse_url($uri);
55 |
56 | if (false === $parts) {
57 | throw new InvalidArgumentException(
58 | 'The source URI string appears to be malformed'
59 | );
60 | }
61 |
62 | $scheme = isset($parts['scheme']) ? self::filterScheme($parts['scheme']) : '';
63 | $host = isset($parts['host']) ? \strtolower($parts['host']) : '';
64 | $port = isset($parts['port']) ? (int) $parts['port'] : self::TCP_PORT_DEFAULT;
65 | $userCredentials = null;
66 |
67 | if (isset($parts['user'])) {
68 | $user = self::filterUserInfoPart($parts['user']);
69 | $pass = $parts['pass'] ?? '';
70 |
71 | $userCredentials = new UserCredentials($user, $pass);
72 | }
73 |
74 | return new self($scheme, $host, $port, $userCredentials);
75 | }
76 |
77 | public function scheme(): string
78 | {
79 | return $this->scheme;
80 | }
81 |
82 | public function userCredentials(): ?UserCredentials
83 | {
84 | return $this->userCredentials;
85 | }
86 |
87 | public function host(): string
88 | {
89 | return $this->host;
90 | }
91 |
92 | public function port(): int
93 | {
94 | return $this->port;
95 | }
96 |
97 | private static function filterScheme(string $scheme): string
98 | {
99 | return \preg_replace('#:(//)?$#', '', \strtolower($scheme));
100 | }
101 |
102 | private static function filterUserInfoPart(string $part): string
103 | {
104 | // Note the addition of `%` to initial charset; this allows `|` portion
105 | // to match and thus prevent double-encoding.
106 | return \preg_replace_callback(
107 | '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
108 | function (array $matches): string {
109 | return \rawurlencode($matches[0]);
110 | },
111 | $part
112 | );
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/UserManagement/UsersManager.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\UserManagement;
15 |
16 | use Http\Message\RequestFactory;
17 | use Prooph\EventStore\Exception\EventStoreConnectionException;
18 | use Prooph\EventStore\Exception\InvalidArgumentException;
19 | use Prooph\EventStore\Internal\DateTimeStringBugWorkaround;
20 | use Prooph\EventStore\Transport\Http\HttpStatusCode;
21 | use Prooph\EventStore\UserCredentials;
22 | use Prooph\EventStore\UserManagement\ChangePasswordDetails;
23 | use Prooph\EventStore\UserManagement\ResetPasswordDetails;
24 | use Prooph\EventStore\UserManagement\UserCreationInformation;
25 | use Prooph\EventStore\UserManagement\UserDetails;
26 | use Prooph\EventStore\UserManagement\UsersManager as SyncUsersManager;
27 | use Prooph\EventStore\UserManagement\UserUpdateInformation;
28 | use Prooph\EventStore\Util\Json;
29 | use Prooph\EventStoreHttpClient\ConnectionSettings;
30 | use Prooph\EventStoreHttpClient\Exception\UserCommandConflictException;
31 | use Prooph\EventStoreHttpClient\Exception\UserCommandFailed;
32 | use Prooph\EventStoreHttpClient\Http\HttpClient;
33 | use Psr\Http\Client\ClientInterface;
34 | use Psr\Http\Message\ResponseInterface;
35 | use Throwable;
36 |
37 | class UsersManager implements SyncUsersManager
38 | {
39 | private ConnectionSettings $settings;
40 | private HttpClient $httpClient;
41 |
42 | /** @internal */
43 | public function __construct(
44 | ConnectionSettings $settings,
45 | ClientInterface $client,
46 | RequestFactory $requestFactory
47 | ) {
48 | $this->settings = $settings;
49 |
50 | $this->httpClient = new HttpClient(
51 | $client,
52 | $requestFactory,
53 | $settings,
54 | \sprintf(
55 | '%s://%s:%s',
56 | $settings->schema(),
57 | $settings->endPoint()->host(),
58 | $settings->endPoint()->port()
59 | )
60 | );
61 | }
62 |
63 | public function enable(string $login, ?UserCredentials $userCredentials = null): void
64 | {
65 | if (empty($login)) {
66 | throw new InvalidArgumentException('Login cannot be empty');
67 | }
68 |
69 | $this->sendPost(
70 | \sprintf(
71 | '/users/%s/command/enable',
72 | \urlencode($login)
73 | ),
74 | '',
75 | $userCredentials,
76 | HttpStatusCode::OK
77 | );
78 | }
79 |
80 | public function disable(string $login, ?UserCredentials $userCredentials = null): void
81 | {
82 | if (empty($login)) {
83 | throw new InvalidArgumentException('Login cannot be empty');
84 | }
85 |
86 | $this->sendPost(
87 | \sprintf(
88 | '/users/%s/command/disable',
89 | \urlencode($login)
90 | ),
91 | '',
92 | $userCredentials,
93 | HttpStatusCode::OK
94 | );
95 | }
96 |
97 | /** @throws UserCommandFailed */
98 | public function deleteUser(string $login, ?UserCredentials $userCredentials = null): void
99 | {
100 | if (empty($login)) {
101 | throw new InvalidArgumentException('Login cannot be empty');
102 | }
103 |
104 | $this->sendDelete(
105 | \sprintf(
106 | '/users/%s',
107 | \urlencode($login)
108 | ),
109 | $userCredentials,
110 | HttpStatusCode::OK
111 | );
112 | }
113 |
114 | /** @return list */
115 | public function listAll(?UserCredentials $userCredentials = null): array
116 | {
117 | $response = $this->sendGet(
118 | '/users/',
119 | $userCredentials,
120 | HttpStatusCode::OK
121 | );
122 |
123 | $data = Json::decode($response->getBody()->getContents());
124 |
125 | $userDetails = [];
126 |
127 | foreach ($data['data'] as $entry) {
128 | if (isset($entry['dateLastUpdated'])) {
129 | $entry['dateLastUpdated'] = DateTimeStringBugWorkaround::fixDateTimeString(
130 | $entry['dateLastUpdated']
131 | );
132 | }
133 |
134 | $userDetails[] = UserDetails::fromArray($entry);
135 | }
136 |
137 | return $userDetails;
138 | }
139 |
140 | public function getCurrentUser(?UserCredentials $userCredentials = null): UserDetails
141 | {
142 | $response = $this->sendGet(
143 | '/users/$current',
144 | $userCredentials,
145 | HttpStatusCode::OK
146 | );
147 |
148 | $data = Json::decode($response->getBody()->getContents());
149 |
150 | if (isset($data['data']['dateLastUpdated'])) {
151 | $data['data']['dateLastUpdated'] = DateTimeStringBugWorkaround::fixDateTimeString(
152 | $data['data']['dateLastUpdated']
153 | );
154 | }
155 |
156 | return UserDetails::fromArray($data['data']);
157 | }
158 |
159 | public function getUser(string $login, ?UserCredentials $userCredentials = null): UserDetails
160 | {
161 | if (empty($login)) {
162 | throw new InvalidArgumentException('Login cannot be empty');
163 | }
164 |
165 | $response = $this->sendGet(
166 | \sprintf(
167 | '/users/%s',
168 | \urlencode($login)
169 | ),
170 | $userCredentials,
171 | HttpStatusCode::OK
172 | );
173 |
174 | $data = Json::decode($response->getBody()->getContents());
175 |
176 | if (isset($data['data']['dateLastUpdated'])) {
177 | $data['data']['dateLastUpdated'] = DateTimeStringBugWorkaround::fixDateTimeString(
178 | $data['data']['dateLastUpdated']
179 | );
180 | }
181 |
182 | return UserDetails::fromArray($data['data']);
183 | }
184 |
185 | /**
186 | * @param list $groups
187 | */
188 | public function createUser(
189 | string $login,
190 | string $fullName,
191 | array $groups,
192 | string $password,
193 | ?UserCredentials $userCredentials = null
194 | ): void {
195 | if (empty($login)) {
196 | throw new InvalidArgumentException('Login cannot be empty');
197 | }
198 |
199 | if (empty($fullName)) {
200 | throw new InvalidArgumentException('FullName cannot be empty');
201 | }
202 |
203 | if (empty($password)) {
204 | throw new InvalidArgumentException('Password cannot be empty');
205 | }
206 |
207 | $this->sendPost(
208 | '/users/',
209 | Json::encode(new UserCreationInformation(
210 | $login,
211 | $fullName,
212 | $groups,
213 | $password
214 | )),
215 | $userCredentials,
216 | HttpStatusCode::CREATED
217 | );
218 | }
219 |
220 | /**
221 | * @param list $groups
222 | */
223 | public function updateUser(
224 | string $login,
225 | string $fullName,
226 | array $groups,
227 | ?UserCredentials $userCredentials = null
228 | ): void {
229 | if (empty($login)) {
230 | throw new InvalidArgumentException('Login cannot be empty');
231 | }
232 |
233 | if (empty($fullName)) {
234 | throw new InvalidArgumentException('FullName cannot be empty');
235 | }
236 |
237 | $this->sendPut(
238 | \sprintf(
239 | '/users/%s',
240 | \urlencode($login)
241 | ),
242 | Json::encode(new UserUpdateInformation($fullName, $groups)),
243 | $userCredentials,
244 | HttpStatusCode::OK
245 | );
246 | }
247 |
248 | public function changePassword(
249 | string $login,
250 | string $oldPassword,
251 | string $newPassword,
252 | ?UserCredentials $userCredentials = null
253 | ): void {
254 | if (empty($login)) {
255 | throw new InvalidArgumentException('Login cannot be empty');
256 | }
257 |
258 | if (empty($oldPassword)) {
259 | throw new InvalidArgumentException('Old password cannot be empty');
260 | }
261 |
262 | if (empty($newPassword)) {
263 | throw new InvalidArgumentException('New password cannot be empty');
264 | }
265 |
266 | $this->sendPost(
267 | \sprintf(
268 | '/users/%s/command/change-password',
269 | \urlencode($login)
270 | ),
271 | Json::encode(new ChangePasswordDetails($oldPassword, $newPassword)),
272 | $userCredentials,
273 | HttpStatusCode::OK
274 | );
275 | }
276 |
277 | public function resetPassword(
278 | string $login,
279 | string $newPassword,
280 | ?UserCredentials $userCredentials = null
281 | ): void {
282 | if (empty($login)) {
283 | throw new InvalidArgumentException('Login cannot be empty');
284 | }
285 |
286 | if (empty($newPassword)) {
287 | throw new InvalidArgumentException('New password cannot be empty');
288 | }
289 |
290 | $this->sendPost(
291 | \sprintf(
292 | '/users/%s/command/reset-password',
293 | \urlencode($login)
294 | ),
295 | Json::encode(new ResetPasswordDetails($newPassword)),
296 | $userCredentials,
297 | HttpStatusCode::OK
298 | );
299 | }
300 |
301 | private function sendGet(
302 | string $uri,
303 | ?UserCredentials $userCredentials,
304 | int $expectedCode
305 | ): ResponseInterface {
306 | $response = $this->httpClient->get($uri, [], $userCredentials, static function (Throwable $e) {
307 | throw new EventStoreConnectionException($e->getMessage());
308 | });
309 |
310 | if ($response->getStatusCode() !== $expectedCode) {
311 | throw new UserCommandFailed(
312 | $response->getStatusCode(),
313 | \sprintf(
314 | 'Server returned %d (%s) for GET on %s',
315 | $response->getStatusCode(),
316 | $response->getReasonPhrase(),
317 | $uri
318 | )
319 | );
320 | }
321 |
322 | return $response;
323 | }
324 |
325 | private function sendDelete(
326 | string $uri,
327 | ?UserCredentials $userCredentials,
328 | int $expectedCode
329 | ): void {
330 | $response = $this->httpClient->delete($uri, [], $userCredentials, static function (Throwable $e) {
331 | throw new EventStoreConnectionException($e->getMessage());
332 | });
333 |
334 | if ($response->getStatusCode() !== $expectedCode) {
335 | throw new UserCommandFailed(
336 | $response->getStatusCode(),
337 | \sprintf(
338 | 'Server returned %d (%s) for DELETE on %s',
339 | $response->getStatusCode(),
340 | $response->getReasonPhrase(),
341 | $uri
342 | )
343 | );
344 | }
345 | }
346 |
347 | private function sendPut(
348 | string $uri,
349 | string $content,
350 | ?UserCredentials $userCredentials,
351 | int $expectedCode
352 | ): void {
353 | $response = $this->httpClient->put(
354 | $uri,
355 | ['Content-Type' => 'application/json'],
356 | $content,
357 | $userCredentials,
358 | static function (Throwable $e) {
359 | throw new EventStoreConnectionException($e->getMessage());
360 | }
361 | );
362 |
363 | if ($response->getStatusCode() !== $expectedCode) {
364 | throw new UserCommandFailed(
365 | $response->getStatusCode(),
366 | \sprintf(
367 | 'Server returned %d (%s) for PUT on %s',
368 | $response->getStatusCode(),
369 | $response->getReasonPhrase(),
370 | $uri
371 | )
372 | );
373 | }
374 | }
375 |
376 | private function sendPost(
377 | string $uri,
378 | string $content,
379 | ?UserCredentials $userCredentials,
380 | int $expectedCode
381 | ): void {
382 | $response = $this->httpClient->post(
383 | $uri,
384 | ['Content-Type' => 'application/json'],
385 | $content,
386 | $userCredentials,
387 | static function (Throwable $e) {
388 | throw new EventStoreConnectionException($e->getMessage());
389 | }
390 | );
391 |
392 | if ($response->getStatusCode() === HttpStatusCode::CONFLICT) {
393 | throw new UserCommandConflictException($response->getStatusCode(), $response->getReasonPhrase());
394 | }
395 |
396 | if ($response->getStatusCode() !== $expectedCode) {
397 | throw new UserCommandFailed(
398 | $response->getStatusCode(),
399 | \sprintf(
400 | 'Server returned %d (%s) for POST on %s',
401 | $response->getStatusCode(),
402 | $response->getReasonPhrase(),
403 | $uri
404 | )
405 | );
406 | }
407 | }
408 | }
409 |
--------------------------------------------------------------------------------
/src/UserManagement/UsersManagerFactory.php:
--------------------------------------------------------------------------------
1 |
6 | * (c) 2018-2020 Sascha-Oliver Prolic
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | declare(strict_types=1);
13 |
14 | namespace Prooph\EventStoreHttpClient\UserManagement;
15 |
16 | use Http\Discovery\HttpClientDiscovery;
17 | use Http\Discovery\MessageFactoryDiscovery;
18 | use Http\Message\RequestFactory;
19 | use Prooph\EventStore\UserManagement\UsersManager as SyncUsersManager;
20 | use Prooph\EventStoreHttpClient\ConnectionSettings;
21 | use Psr\Http\Client\ClientInterface;
22 |
23 | class UsersManagerFactory
24 | {
25 | public static function create(
26 | ConnectionSettings $settings = null,
27 | ClientInterface $client = null,
28 | RequestFactory $requestFactory = null
29 | ): SyncUsersManager {
30 | return new UsersManager(
31 | $settings ?? ConnectionSettings::default(),
32 | $client ?? HttpClientDiscovery::find(),
33 | $requestFactory ?? MessageFactoryDiscovery::find()
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------