├── .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 | [![Build Status](https://travis-ci.org/prooph/event-store-http-client.svg?branch=master)](https://travis-ci.org/prooph/event-store-http-client) 6 | [![Coverage Status](https://coveralls.io/repos/github/prooph/event-store-http-client/badge.svg?branch=master)](https://coveralls.io/github/prooph/event-store-http-client?branch=master) 7 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](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 | --------------------------------------------------------------------------------