├── .gitignore ├── .php_cs ├── .travis.yml ├── LICENSE.md ├── README.md ├── bin └── run-ges.sh ├── composer.json ├── phpunit.xml ├── src ├── Client │ ├── Exception │ │ ├── AccessDeniedException.php │ │ ├── BadRequestException.php │ │ ├── NotAuthenticatedException.php │ │ ├── NotHandledException.php │ │ ├── NotReadyException.php │ │ ├── OperationException.php │ │ ├── OperationTimedOutException.php │ │ ├── ServerException.php │ │ ├── StreamDeletedException.php │ │ ├── SystemException.php │ │ ├── TooBusyException.php │ │ └── WrongExpectedVersionException.php │ └── Http │ │ ├── AbstractResponseInspector.php │ │ ├── AppendToStreamRequestFactory.php │ │ ├── AppendToStreamResponseInspector.php │ │ ├── ContentType.php │ │ ├── DeleteStreamRequestFactory.php │ │ ├── DeleteStreamResponseInspector.php │ │ ├── Feed │ │ ├── EventStreamFeed.php │ │ ├── EventStreamFeedIterator.php │ │ ├── EventStreamFeedLink.php │ │ └── EventStreamIterator.php │ │ ├── HttpClient.php │ │ ├── ReadEventStreamFeedRequestFactory.php │ │ ├── ReadEventStreamFeedResponseInspector.php │ │ ├── RequestFactoryInterface.php │ │ ├── RequestHeader.php │ │ └── ResponseInspector.php ├── ClientInterface.php ├── EventData.php ├── EventDataCollection.php ├── EventRecord.php ├── EventRecordCollection.php ├── ExpectedVersion.php ├── ReadDirection.php ├── StreamId.php ├── UserCredentials.php ├── Util │ ├── InternalIterator.php │ └── InternalIteratorTrait.php └── Uuid.php └── tests ├── Integration ├── ClientTest.php └── IntegrationTestCase.php ├── TestCase.php └── Unit ├── Http └── AppendToStreamRequestFactoryTest.php ├── UserCredentialsTest.php └── UuidTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | composer.lock 4 | .php_cs.cache 5 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ; 6 | 7 | return Symfony\CS\Config\Config::create() 8 | ->setUsingCache(true) 9 | ->level(Symfony\CS\FixerInterface::SYMFONY_LEVEL) 10 | ->fixers(array('align_double_arrow', 'concat_with_spaces')) 11 | ->finder($finder) 12 | ; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | sudo: required 3 | 4 | services: 5 | - docker 6 | 7 | language: php 8 | 9 | php: 10 | - 7.0 11 | - hhvm 12 | - nightly 13 | 14 | matrix: 15 | allow_failures: 16 | - php: hhvm 17 | fast_finish: true 18 | 19 | before_install: 20 | - bin/run-ges.sh 21 | - docker ps 22 | - travis_retry composer update --no-interaction --prefer-source 23 | 24 | after_script: 25 | - docker logs geteventstore -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Raymond RUTJES 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/rayrutjes/php-geteventstore-client.svg)](https://travis-ci.org/rayrutjes/php-geteventstore-client) 2 | [![License](https://img.shields.io/github/license/rayrutjes/php-geteventstore-client.svg)](LICENSE.md) 3 | [![Dependencies](https://img.shields.io/gemnasium/rayrutjes/php-geteventstore-client.svg)](https://gemnasium.com/rayrutjes/php-geteventstore-client) 4 | [![Code Quality](https://img.shields.io/scrutinizer/g/rayrutjes/php-geteventstore-client.svg)](https://scrutinizer-ci.com/g/rayrutjes/php-geteventstore-client/) 5 | 6 | # PHP GetEventStore client 7 | 8 | This is a PHP client library for communicating with [GetEventStore](https://geteventstore.com/). 9 | 10 | For now the it as been tested against V3.4.0 API, it has yet to be tested against other versions. 11 | 12 | ## Requirements 13 | 14 | Your PHP version should be at least 7.0.0., I think this shouldn't be a problem. 15 | 16 | Event Sourcing generally has to be considered from the start of your project. 17 | 18 | And as you are starting a new project, why not start it in PHP 7? 19 | 20 | ## Installation 21 | 22 | ```bash 23 | $ composer require rayrutjes/php-geteventstore-client 24 | ``` 25 | 26 | For now there only is an http client. You need to append Guzzle to your project. 27 | 28 | ```bash 29 | $ composer require guzzlehttp/guzzle 30 | ``` 31 | 32 | ## Testing 33 | 34 | ```bash 35 | $ composer update 36 | $ vendor/bin/phpunit 37 | ``` 38 | 39 | Tests will be looking for an environment variable `GES_BASE_URI`. If none is found, it will use the default `http://127.0.0.1:2113` uri for communication with the event store. 40 | 41 | ## Some opinionated choices 42 | 43 | - We never use xml as a content type for http messages as it makes bodies grow in size. 44 | - For now, we use embed bodies in feeds to lower the number of http requests. 45 | 46 | ## Contributing 47 | 48 | Please feel free to contribute by any means. -------------------------------------------------------------------------------- /bin/run-ges.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker run --name geteventstore --detach --publish 2113:2113 --publish 1113:1113 adbrowne/eventstore -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rayrutjes/php-geteventstore-client", 3 | "require": { 4 | "php": "~7.0", 5 | "ramsey/uuid": "^3.1" 6 | }, 7 | "require-dev": { 8 | "guzzlehttp/guzzle": "~6.0", 9 | "phpunit/phpunit": "^5.2" 10 | }, 11 | "suggest": { 12 | "guzzlehttp/guzzle": "Required when using the http client." 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "rayrutjes", 18 | "email": "raymond.rutjes@gmail.com" 19 | } 20 | ], 21 | "autoload": { 22 | "psr-4": {"RayRutjes\\GetEventStore\\": "src/"} 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "RayRutjes\\GetEventStore\\Test\\": "tests/" 27 | } 28 | }, 29 | "minimum-stability": "stable" 30 | } 31 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests/ 14 | 15 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Client/Exception/AccessDeniedException.php: -------------------------------------------------------------------------------- 1 | getStatusCode() === 401) { 19 | throw new AccessDeniedException($response->getReasonPhrase(), $response->getStatusCode()); 20 | } 21 | 22 | if ($response->getStatusCode() === 410) { 23 | throw new StreamDeletedException($response->getReasonPhrase(), $response->getStatusCode()); 24 | } 25 | } 26 | 27 | /** 28 | * @param $response 29 | * 30 | * @return BadRequestException 31 | */ 32 | protected function newBadRequestException(ResponseInterface $response) 33 | { 34 | $message = sprintf( 35 | 'Unhandled response code %d: %s', 36 | $response->getStatusCode(), 37 | $response->getReasonPhrase() 38 | ); 39 | 40 | return new BadRequestException($message, $response->getStatusCode()); 41 | } 42 | 43 | /** 44 | * @param ResponseInterface $response 45 | * 46 | * @return array 47 | */ 48 | protected function decodeResponseBody(ResponseInterface $response): array 49 | { 50 | return $this->decodeData($response->getBody()->getContents()); 51 | } 52 | 53 | /** 54 | * @param string $data 55 | * 56 | * @return array 57 | */ 58 | protected function decodeData(string $data): array 59 | { 60 | $decoded = json_decode($data, true); 61 | 62 | return !is_array($decoded) ? [] : $decoded; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Client/Http/AppendToStreamRequestFactory.php: -------------------------------------------------------------------------------- 1 | streamId = $streamId; 40 | $this->expectedVersion = $expectedVersion; 41 | $this->events = $events; 42 | } 43 | 44 | /** 45 | * @return RequestInterface 46 | */ 47 | public function buildRequest(): RequestInterface 48 | { 49 | return new Request( 50 | 'POST', 51 | sprintf('streams/%s', $this->streamId->toString()), 52 | [ 53 | RequestHeader::CONTENT_TYPE => ContentType::JSON_ES, 54 | RequestHeader::EXPECTED_VERSION => $this->expectedVersion->toInt(), 55 | ], 56 | $this->buildBody() 57 | ); 58 | } 59 | 60 | /** 61 | * @return string 62 | */ 63 | private function buildBody(): string 64 | { 65 | $data = []; 66 | foreach ($this->events as $event) { 67 | /* @var $event EventData */ 68 | $data[] = [ 69 | 'eventId' => $event->getEventId()->toString(), 70 | 'eventType' => $event->getType(), 71 | 'data' => $event->getData(), 72 | 'metadata' => $event->getMetadata(), 73 | ]; 74 | } 75 | 76 | return json_encode($data); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Client/Http/AppendToStreamResponseInspector.php: -------------------------------------------------------------------------------- 1 | filterCommonErrors($response); 18 | switch ($response->getStatusCode()) { 19 | 20 | /* OK */ 21 | case 201: 22 | break; 23 | 24 | /* 25 | * The ES Api will try to redirect us if we do not provide an eventId. 26 | * Actually this client is designed to avoid that scenario. 27 | * The httpClient does not allow redirects anyway. 28 | * See: http://docs.geteventstore.com/http-api/3.4.0/writing-to-a-stream/#expected-version 29 | */ 30 | case 301: 31 | throw new \LogicException('Please help us understand how you got here!!!'); 32 | 33 | /* Catch known error, otherwise fall-through to a more generic exception. */ 34 | case 400: 35 | if ($response->getReasonPhrase() == 'Wrong expected EventNumber') { 36 | throw new WrongExpectedVersionException(); 37 | } 38 | 39 | /* KO. */ 40 | default: 41 | throw $this->newBadRequestException($response); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Client/Http/ContentType.php: -------------------------------------------------------------------------------- 1 | streamId = $streamId; 22 | } 23 | 24 | /** 25 | * @return RequestInterface 26 | */ 27 | public function buildRequest(): RequestInterface 28 | { 29 | return new Request( 30 | 'DELETE', 31 | sprintf('streams/%s', $this->streamId->toString()), 32 | [ 33 | RequestHeader::HARD_DELETE => 'true', 34 | ] 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Client/Http/DeleteStreamResponseInspector.php: -------------------------------------------------------------------------------- 1 | filterCommonErrors($response); 15 | switch ($response->getStatusCode()) { 16 | case 204: 17 | // OK. 18 | break; 19 | default: 20 | // KO. 21 | throw $this->newBadRequestException($response); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Client/Http/Feed/EventStreamFeed.php: -------------------------------------------------------------------------------- 1 | validateEvents($events); 38 | $this->events = $events; 39 | 40 | foreach ($links as $link) { 41 | $this->validateLink($link); 42 | $this->links[$link->getRelation()] = $link; 43 | } 44 | $this->isHeadOfStream = $isHeadOfStream; 45 | $this->eTag = $eTag; 46 | } 47 | 48 | /** 49 | * @param array $events 50 | * 51 | * @return mixed 52 | */ 53 | private function validateEvents(array $events) 54 | { 55 | foreach ($events as $event) { 56 | if (!$event instanceof EventRecord) { 57 | throw new \InvalidArgumentException(sprintf('Expected EventRecord, got %s', get_class($event))); 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * @param $link 64 | */ 65 | private function validateLink($link) 66 | { 67 | if (!$link instanceof EventStreamFeedLink) { 68 | throw new \InvalidArgumentException('Invalid link type %s.', get_class($link)); 69 | } 70 | if (isset($this->links[$link->getRelation()])) { 71 | throw new \InvalidArgumentException(sprintf('Link relation %s already there.', $link->getRelation())); 72 | } 73 | } 74 | 75 | /** 76 | * @return bool 77 | */ 78 | public function hasPreviousLink(): bool 79 | { 80 | return isset($this->links[EventStreamFeedLink::LINK_PREVIOUS]); 81 | } 82 | 83 | /** 84 | * @return EventStreamFeedLink 85 | */ 86 | public function getPreviousLink(): EventStreamFeedLink 87 | { 88 | return $this->links[EventStreamFeedLink::LINK_PREVIOUS]; 89 | } 90 | 91 | /** 92 | * @return bool 93 | */ 94 | public function hasNextLink(): bool 95 | { 96 | return isset($this->links[EventStreamFeedLink::LINK_NEXT]); 97 | } 98 | 99 | /** 100 | * @return EventStreamFeedLink 101 | */ 102 | public function getNextLink(): EventStreamFeedLink 103 | { 104 | return $this->links[EventStreamFeedLink::LINK_NEXT]; 105 | } 106 | 107 | /** 108 | * @return bool 109 | */ 110 | public function hasLastLink(): bool 111 | { 112 | return isset($this->links[EventStreamFeedLink::LINK_LAST]); 113 | } 114 | 115 | /** 116 | * @return EventStreamFeedLink 117 | */ 118 | public function getLastLink(): EventStreamFeedLink 119 | { 120 | return $this->links[EventStreamFeedLink::LINK_LAST]; 121 | } 122 | 123 | /** 124 | * @return string 125 | */ 126 | public function getETag(): string 127 | { 128 | return $this->eTag; 129 | } 130 | 131 | /** 132 | * @return array 133 | */ 134 | public function getEvents(): array 135 | { 136 | return $this->events; 137 | } 138 | 139 | /** 140 | * @return bool 141 | */ 142 | public function isHeadOfStream(): bool 143 | { 144 | return $this->isHeadOfStream; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Client/Http/Feed/EventStreamFeedIterator.php: -------------------------------------------------------------------------------- 1 | streamId = $streamId; 65 | $this->client = $client; 66 | $this->headUri = sprintf('streams/%s', $streamId->toString()); 67 | 68 | $direction = $readFromStart ? ReadDirection::FORWARD : ReadDirection::BACKWARD; 69 | $this->readDirection = new ReadDirection($direction); 70 | } 71 | 72 | /** 73 | * @param string $uri 74 | * 75 | * @return EventStreamFeed 76 | */ 77 | private function readEventStreamFeed(string $uri): EventStreamFeed 78 | { 79 | $factory = new ReadEventStreamFeedRequestFactory($uri); 80 | $inspector = new ReadEventStreamFeedResponseInspector(); 81 | $this->client->send($factory->buildRequest(), $inspector); 82 | 83 | return $inspector->getFeed(); 84 | } 85 | 86 | /** 87 | * @return EventStreamFeed 88 | */ 89 | public function current(): EventStreamFeed 90 | { 91 | if (null === $this->currentFeed) { 92 | if (true === $this->initialized) { 93 | throw new \OutOfBoundsException('Stream overflow.'); 94 | } else { 95 | $this->rewind(); 96 | } 97 | } 98 | 99 | return $this->currentFeed; 100 | } 101 | 102 | public function next() 103 | { 104 | $this->currentKey++; 105 | if ($this->readDirection->isForward()) { 106 | return $this->readForward(); 107 | } 108 | $this->readBackward(); 109 | } 110 | 111 | private function readForward() 112 | { 113 | if ($this->currentFeed->hasPreviousLink() && !$this->currentFeed->isHeadOfStream()) { 114 | $this->currentFeed = $this->readEventStreamFeed($this->currentFeed->getPreviousLink()->getUri()); 115 | 116 | return; 117 | } 118 | $this->currentFeed = null; 119 | } 120 | 121 | private function readBackward() 122 | { 123 | if ($this->currentFeed->hasNextLink()) { 124 | $this->currentFeed = $this->readEventStreamFeed($this->currentFeed->getNextLink()->getUri()); 125 | 126 | return; 127 | } 128 | $this->currentFeed = null; 129 | } 130 | 131 | /** 132 | * @return int 133 | */ 134 | public function key(): int 135 | { 136 | return $this->currentKey; 137 | } 138 | 139 | /** 140 | * @return bool 141 | */ 142 | public function valid(): bool 143 | { 144 | return null !== $this->currentFeed; 145 | } 146 | 147 | public function rewind() 148 | { 149 | $this->currentKey = 0; 150 | if ($this->readDirection->isForward()) { 151 | $this->startUri = $this->getStartUri(); 152 | $this->currentFeed = $this->readEventStreamFeed($this->startUri); 153 | } else { 154 | $this->currentFeed = $this->readEventStreamFeed($this->headUri); 155 | } 156 | $this->initialized = true; 157 | // Todo: not sure how we should handle errors here. 158 | // Todo: for now, let it bubble. 159 | } 160 | 161 | /** 162 | * @return string 163 | */ 164 | private function getStartUri(): string 165 | { 166 | if (null !== $this->startUri) { 167 | return $this->startUri; 168 | } 169 | 170 | // It is recommended to not guess the uris for backward compatibility. 171 | // So we have to make an intermediary request to get the start uri. 172 | $feed = $this->readEventStreamFeed($this->headUri); 173 | if ($feed->hasLastLink()) { 174 | return $feed->getLastLink()->getUri(); 175 | } 176 | 177 | // If we came so far it means we are dealing with a feed of exactly one page. 178 | // So head = start. 179 | return $this->headUri; 180 | } 181 | 182 | /** 183 | * @return ReadDirection 184 | */ 185 | public function getReadDirection() 186 | { 187 | return $this->readDirection; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Client/Http/Feed/EventStreamFeedLink.php: -------------------------------------------------------------------------------- 1 | validRelations)) { 36 | throw new \InvalidArgumentException(sprintf('Invalid link relation %s.', $relation)); 37 | } 38 | $this->uri = $uri; 39 | $this->relation = $relation; 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function getUri(): string 46 | { 47 | return $this->uri; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getRelation(): string 54 | { 55 | return $this->relation; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Client/Http/Feed/EventStreamIterator.php: -------------------------------------------------------------------------------- 1 | feedIterator = $feedIterator; 30 | } 31 | 32 | /** 33 | * @return EventRecord 34 | */ 35 | public function current(): EventRecord 36 | { 37 | return $this->eventsIterator->current(); 38 | } 39 | 40 | public function next() 41 | { 42 | $this->eventsIterator->next(); 43 | $this->currentKey++; 44 | if ($this->eventsIterator->valid()) { 45 | return; 46 | } 47 | 48 | $this->feedIterator->next(); 49 | if ($this->feedIterator->valid()) { 50 | $this->eventsIterator = $this->newEventsIterator(); 51 | } else { 52 | $this->feedIterator = null; 53 | } 54 | } 55 | 56 | /** 57 | * @return \ArrayIterator 58 | */ 59 | private function newEventsIterator() 60 | { 61 | $events = $this->feedIterator->current()->getEvents(); 62 | if ($this->feedIterator->getReadDirection()->isForward()) { 63 | $events = array_reverse($events); 64 | } 65 | 66 | return new \ArrayIterator($events); 67 | } 68 | 69 | /** 70 | * @return int 71 | */ 72 | public function key() 73 | { 74 | return $this->currentKey; 75 | } 76 | 77 | /** 78 | * @return bool 79 | */ 80 | public function valid(): bool 81 | { 82 | return $this->eventsIterator->valid(); 83 | } 84 | 85 | public function rewind() 86 | { 87 | $this->currentKey = 0; 88 | $this->eventsIterator = $this->newEventsIterator(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Client/Http/HttpClient.php: -------------------------------------------------------------------------------- 1 | $baseUri, 42 | 'allow_redirects' => false, 43 | 'connect_timeout' => $connectTimeout, 44 | 'auth' => [$credentials->getLogin(), $credentials->getPassword()], 45 | 'http_errors' => false, // Let the client handle the status codes for now. 46 | ] 47 | ); 48 | $this->httpClient = new Client($options); 49 | } 50 | 51 | /** 52 | * @param string $streamId 53 | * @param int $expectedVersion 54 | * @param array $events 55 | */ 56 | public function appendToStream(string $streamId, int $expectedVersion, array $events) 57 | { 58 | $events = EventDataCollection::fromArray($events); 59 | if (0 === $events->count()) { 60 | throw new \InvalidArgumentException('No events provided.'); 61 | } 62 | 63 | $streamId = new StreamId($streamId); 64 | if ($streamId->isSystem()) { 65 | throw new \InvalidArgumentException(sprintf('Can not append to system stream %s', $streamId)); 66 | } 67 | 68 | $expectedVersion = new ExpectedVersion($expectedVersion); 69 | 70 | $request = new AppendToStreamRequestFactory($streamId, $expectedVersion, $events); 71 | 72 | $this->send($request->buildRequest(), new AppendToStreamResponseInspector()); 73 | } 74 | 75 | /** 76 | * @param RequestInterface $request 77 | * @param ResponseInspector $inspector 78 | * 79 | * @return ResponseInterface 80 | * 81 | * @internal 82 | */ 83 | public function send(RequestInterface $request, ResponseInspector $inspector): ResponseInterface 84 | { 85 | try { 86 | $response = $this->httpClient->send($request); 87 | } catch (TransferException $e) { 88 | $this->handleTransferException($e); 89 | } 90 | 91 | $inspector->inspect($response); 92 | 93 | return $response; 94 | } 95 | 96 | /** 97 | * @param $e 98 | */ 99 | private function handleTransferException(TransferException $e) 100 | { 101 | throw new SystemException($e->getMessage(), $e->getCode(), $e); 102 | } 103 | 104 | /** 105 | * @param string $streamId 106 | */ 107 | public function deleteStream(string $streamId) 108 | { 109 | $streamId = new StreamId($streamId); 110 | if ($streamId->isSystem()) { 111 | throw new \InvalidArgumentException( 112 | sprintf('Can not delete system stream with id %s', $streamId->toString()) 113 | ); 114 | } 115 | 116 | $factory = new DeleteStreamRequestFactory($streamId); 117 | $this->send($factory->buildRequest(), new DeleteStreamResponseInspector()); 118 | } 119 | 120 | /** 121 | * With great power comes great responsibility. 122 | * 123 | * @return EventRecordCollection 124 | */ 125 | public function readAllEvents(): EventRecordCollection 126 | { 127 | $streamId = new StreamId(StreamId::ALL); 128 | 129 | return $this->readAllEventsFromStream($streamId->toString()); 130 | } 131 | 132 | /** 133 | * @param string $streamId 134 | * 135 | * @return EventRecordCollection 136 | */ 137 | public function readAllEventsFromStream(string $streamId): EventRecordCollection 138 | { 139 | $streamId = new StreamId($streamId); 140 | $feedsIterator = new EventStreamFeedIterator($streamId, $this, true); 141 | $eventsIterator = new EventStreamIterator($feedsIterator); 142 | 143 | $events = []; 144 | foreach ($eventsIterator as $event) { 145 | $events[] = $event; 146 | } 147 | 148 | return EventRecordCollection::fromArray($events); 149 | } 150 | 151 | /** 152 | * Retrieves events recorded since a given version of the stream. 153 | * Does not include the event with number corresponding to the given version. 154 | * 155 | * @param string $streamId 156 | * @param int $version 157 | * 158 | * @return EventRecordCollection 159 | */ 160 | public function readStreamUpToVersion(string $streamId, int $version): EventRecordCollection 161 | { 162 | if ($version <= 0) { 163 | throw new \InvalidArgumentException(sprintf('version should be >= 0, got: %d', $version)); 164 | } 165 | 166 | $streamId = new StreamId($streamId); 167 | // Todo: there are probably more streams to avoid. Thinking of system or metadata streams. 168 | if ($streamId->toString() === StreamId::ALL) { 169 | throw new \InvalidArgumentException(sprintf('Can not catch up %s stream.', StreamId::ALL)); 170 | } 171 | 172 | $feedsIterator = new EventStreamFeedIterator($streamId, $this, false); 173 | $eventsIterator = new EventStreamIterator($feedsIterator); 174 | 175 | $events = []; 176 | foreach ($eventsIterator as $event) { 177 | if ($event->getNumber() < $version) { 178 | throw new \InvalidArgumentException( 179 | sprintf('Stream %s has not reached version %d.', $streamId->toString(), $version) 180 | ); 181 | } 182 | 183 | if ($event->getNumber() === $version) { 184 | break; 185 | } 186 | $events[] = $event; 187 | } 188 | 189 | $events = array_reverse($events); 190 | 191 | return EventRecordCollection::fromArray($events); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Client/Http/ReadEventStreamFeedRequestFactory.php: -------------------------------------------------------------------------------- 1 | uri = $uri; 24 | } 25 | 26 | /** 27 | * @return RequestInterface 28 | */ 29 | public function buildRequest(): RequestInterface 30 | { 31 | return new Request( 32 | 'GET', 33 | // Add full event entries to the feed. 34 | $this->uri . '?' . self::EMBED . '=' . self::EMBED_BODY, 35 | [ 36 | RequestHeader::ACCEPT => ContentType::ATOM_JSON, 37 | ] 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Client/Http/ReadEventStreamFeedResponseInspector.php: -------------------------------------------------------------------------------- 1 | filterCommonErrors($response); 23 | switch ($response->getStatusCode()) { 24 | case 200: 25 | // OK. 26 | break; 27 | default: 28 | // KO. 29 | throw $this->newBadRequestException($response); 30 | } 31 | $data = $this->decodeResponseBody($response); 32 | 33 | // Todo: Handle parsing exceptions and throw corresponding errors. 34 | $links = []; 35 | foreach ($data['links'] as $link) { 36 | $links[] = new EventStreamFeedLink($link['uri'], $link['relation']); 37 | } 38 | 39 | $events = []; 40 | foreach ($data['entries'] as $entry) { 41 | $streamId = $entry['streamId']; 42 | 43 | $number = $entry['eventNumber']; 44 | $type = $entry['eventType']; 45 | $eventData = isset($entry['data']) ? $this->decodeData($entry['data']) : []; 46 | $eventMetadata = isset($entry['metaData']) ? $this->decodeData($entry['metaData']) : []; 47 | 48 | $events[] = new EventRecord($streamId, $number, $type, $eventData, $eventMetadata); 49 | } 50 | 51 | $isHeadOfStream = $data['headOfStream']; 52 | $eTag = $data['eTag'] ?? null; 53 | $this->feed = new EventStreamFeed($events, $links, $isHeadOfStream, $eTag); 54 | } 55 | 56 | /** 57 | * @return EventStreamFeed 58 | */ 59 | public function getFeed(): EventStreamFeed 60 | { 61 | return $this->feed; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Client/Http/RequestFactoryInterface.php: -------------------------------------------------------------------------------- 1 | eventId = new Uuid($eventId); 36 | $this->type = $type; 37 | $this->data = $data; 38 | $this->metadata = $metadata; 39 | } 40 | 41 | /** 42 | * @return Uuid 43 | */ 44 | public function getEventId(): Uuid 45 | { 46 | return $this->eventId; 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | public function getType(): string 53 | { 54 | return $this->type; 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getData(): array 61 | { 62 | return $this->data; 63 | } 64 | 65 | /** 66 | * @return array 67 | */ 68 | public function getMetadata(): array 69 | { 70 | return $this->metadata; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/EventDataCollection.php: -------------------------------------------------------------------------------- 1 | iterator instanceof \Countable) { 33 | return $this->iterator->count(); 34 | } 35 | 36 | return 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/EventRecord.php: -------------------------------------------------------------------------------- 1 | streamId = $streamId; 42 | $this->number = $number; 43 | $this->type = $type; 44 | $this->data = $data; 45 | $this->metadata = $metadata; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getStreamId(): string 52 | { 53 | return $this->streamId; 54 | } 55 | 56 | /** 57 | * @return int 58 | */ 59 | public function getNumber(): int 60 | { 61 | return $this->number; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getType(): string 68 | { 69 | return $this->type; 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function getData(): array 76 | { 77 | return $this->data; 78 | } 79 | 80 | /** 81 | * @return array 82 | */ 83 | public function getMetadata(): array 84 | { 85 | return $this->metadata; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/EventRecordCollection.php: -------------------------------------------------------------------------------- 1 | iterator instanceof \Countable) { 33 | return $this->iterator->count(); 34 | } 35 | 36 | return 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ExpectedVersion.php: -------------------------------------------------------------------------------- 1 | value = $value; 36 | } 37 | 38 | /** 39 | * @return int 40 | */ 41 | public function toInt(): int 42 | { 43 | return $this->value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ReadDirection.php: -------------------------------------------------------------------------------- 1 | value = $value; 25 | } 26 | 27 | /** 28 | * @return bool 29 | */ 30 | public function isForward(): bool 31 | { 32 | return $this->value === self::FORWARD; 33 | } 34 | 35 | /** 36 | * @return bool 37 | */ 38 | public function isBackward(): bool 39 | { 40 | return $this->value === self::BACKWARD; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/StreamId.php: -------------------------------------------------------------------------------- 1 | startsWith($value, '$$')) { 38 | $this->isMetadata = true; 39 | } elseif ($this->startsWith($value, '$')) { 40 | $this->isSystem = true; 41 | } 42 | $this->value = $value; 43 | } 44 | 45 | /** 46 | * @param $value 47 | * @param $prefix 48 | * 49 | * @return bool 50 | */ 51 | private function startsWith(string $value, string $prefix): bool 52 | { 53 | return strrpos($value, $prefix, -strlen($value)) !== false; 54 | } 55 | 56 | /** 57 | * @return bool 58 | */ 59 | public function isSystem(): bool 60 | { 61 | return $this->isSystem; 62 | } 63 | 64 | /** 65 | * @return bool 66 | */ 67 | public function isMetadata(): bool 68 | { 69 | return $this->isMetadata; 70 | } 71 | 72 | /** 73 | * @return string 74 | */ 75 | public function toString(): string 76 | { 77 | return $this->value; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/UserCredentials.php: -------------------------------------------------------------------------------- 1 | login = $login; 32 | $this->password = $password; 33 | } 34 | 35 | /** 36 | * @return string 37 | */ 38 | public function getLogin(): string 39 | { 40 | return $this->login; 41 | } 42 | 43 | /** 44 | * @return string 45 | */ 46 | public function getPassword(): string 47 | { 48 | return $this->password; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Util/InternalIterator.php: -------------------------------------------------------------------------------- 1 | iterator = $iterator; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Util/InternalIteratorTrait.php: -------------------------------------------------------------------------------- 1 | iterator->current(); 24 | } 25 | 26 | /** 27 | * Move forward to next element. 28 | * 29 | * @link http://php.net/manual/en/iterator.next.php 30 | * @since 5.0.0 31 | */ 32 | public function next() 33 | { 34 | $this->iterator->next(); 35 | } 36 | 37 | /** 38 | * Return the key of the current element. 39 | * 40 | * @link http://php.net/manual/en/iterator.key.php 41 | * 42 | * @return mixed scalar on success, or null on failure. 43 | * 44 | * @since 5.0.0 45 | */ 46 | public function key() 47 | { 48 | return $this->iterator->key(); 49 | } 50 | 51 | /** 52 | * Checks if current position is valid. 53 | * 54 | * @link http://php.net/manual/en/iterator.valid.php 55 | * 56 | * @return bool The return value will be casted to boolean and then evaluated. 57 | * Returns true on success or false on failure. 58 | * 59 | * @since 5.0.0 60 | */ 61 | public function valid() 62 | { 63 | return $this->iterator->valid(); 64 | } 65 | 66 | /** 67 | * Rewind the Iterator to the first element. 68 | * 69 | * @link http://php.net/manual/en/iterator.rewind.php 70 | * @since 5.0.0 71 | */ 72 | public function rewind() 73 | { 74 | if (null === $this->iterator) { 75 | throw new \LogicException('Missing internal iterator.'); 76 | } 77 | $this->iterator->rewind(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Uuid.php: -------------------------------------------------------------------------------- 1 | value = \Ramsey\Uuid\Uuid::fromString($value); 23 | } 24 | 25 | /** 26 | * @return string 27 | */ 28 | public function toString(): string 29 | { 30 | return $this->value->toString(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Integration/ClientTest.php: -------------------------------------------------------------------------------- 1 | buildClient(); 15 | $client->appendToStream('stream', ExpectedVersion::ANY, []); 16 | } 17 | 18 | public function testCanAppendSingleEvent() 19 | { 20 | $events = $this->getEventDataSet(1); 21 | $streamId = uniqid('testCanAppendSingleEvent'); 22 | $client = $this->buildClient(); 23 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 24 | 25 | $records = $client->readAllEventsFromStream($streamId); 26 | $this->assertEventDataMatchesEventRecords($events, $records, $streamId); 27 | } 28 | 29 | public function testCanDeleteAStream() 30 | { 31 | $events = $this->getEventDataSet(2); 32 | $client = $this->buildClient(); 33 | 34 | // We generate a unique stream to be deleted because once gone, the stream name 35 | // can not be re-used. 36 | $streamId = uniqid('testCanDeleteAStream'); 37 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 38 | $client->deleteStream($streamId); 39 | } 40 | 41 | /** 42 | * @expectedException \RayRutjes\GetEventStore\Client\Exception\StreamDeletedException 43 | */ 44 | public function testDeleteStreamShouldBeGone() 45 | { 46 | $events = $this->getEventDataSet(2); 47 | $client = $this->buildClient(); 48 | 49 | // We generate a unique stream to be deleted because once gone, the stream name 50 | // can not be re-used. 51 | $streamId = uniqid('testDeleteStreamShouldBeGone'); 52 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 53 | $client->deleteStream($streamId); 54 | 55 | // We can not append to a deleted stream. 56 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 57 | } 58 | 59 | public function testCanAppendMultipleEvents() 60 | { 61 | $events = $this->getEventDataSet(3); 62 | $streamId = uniqid('testCanAppendMultipleEvents'); 63 | $client = $this->buildClient(); 64 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 65 | 66 | $records = $client->readAllEventsFromStream($streamId); 67 | $this->assertEventDataMatchesEventRecords($events, $records, $streamId); 68 | } 69 | 70 | public function testCanReadAllEventsOfAStream() 71 | { 72 | $events = $this->getEventDataSet(3); 73 | $streamId = uniqid('testCanReadAllEventsOfAStream'); 74 | $client = $this->buildClient(); 75 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 76 | 77 | $records = $client->readAllEventsFromStream($streamId); 78 | $this->assertEventDataMatchesEventRecords($events, $records, $streamId); 79 | } 80 | 81 | public function testCanReadStreamUpToVersion() 82 | { 83 | $events = $this->getEventDataSet(10); 84 | $streamId = uniqid('testCanReadStreamUpToVersion'); 85 | $client = $this->buildClient(); 86 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 87 | 88 | // We missed a single event. 89 | $records = $client->readStreamUpToVersion($streamId, 9 - 1); 90 | $this->assertEventDataMatchesEventRecords(array_slice($events, 9 + 1 - 1), $records, $streamId, 9 + 1 - 1); 91 | 92 | // We missed 5 events. 93 | $records = $client->readStreamUpToVersion($streamId, 9 - 5); 94 | $this->assertEventDataMatchesEventRecords(array_slice($events, 9 + 1 - 5), $records, $streamId, 9 + 1 - 5); 95 | 96 | // We are up to date. 97 | $records = $client->readStreamUpToVersion($streamId, 9 - 0); 98 | $this->assertEventDataMatchesEventRecords(array_slice($events, 9 + 1 - 0), $records, $streamId, 9 + 1 - 0); 99 | } 100 | 101 | /** 102 | * @expectedException \InvalidArgumentException 103 | */ 104 | public function testStreamCatchUpShouldValidateVersion() 105 | { 106 | $streamId = uniqid('testCanReadStreamUpToVersion'); 107 | $client = $this->buildClient(); 108 | $client->readStreamUpToVersion($streamId, -1); 109 | } 110 | 111 | /** 112 | * @expectedException \InvalidArgumentException 113 | */ 114 | public function testCanNotCatchUpAnOutOfBoundVersion() 115 | { 116 | $events = $this->getEventDataSet(3); 117 | $streamId = uniqid('testCanNotCatchUpAnOutOfBoundVersion'); 118 | $client = $this->buildClient(); 119 | $client->appendToStream($streamId, ExpectedVersion::ANY, $events); 120 | 121 | $client->readStreamUpToVersion($streamId, 5); 122 | } 123 | 124 | public function testCanReadAllEvents() 125 | { 126 | $client = $this->buildClient(); 127 | $client->readAllEvents(); 128 | // todo: this is pretty hard to test. 129 | // todo: I think this function should somehow filter the system and metadata events. 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Integration/IntegrationTestCase.php: -------------------------------------------------------------------------------- 1 | baseUri = $_SERVER['GES_BASE_URI'] ?? 'http://127.0.0.1:2113'; 19 | } 20 | 21 | /** 22 | * @param UserCredentials $credentials 23 | * @param float $connectTimeout 24 | * @param array $options 25 | * 26 | * @return ClientInterface 27 | */ 28 | protected function buildClient(UserCredentials $credentials = null, float $connectTimeout = 0, array $options = []) 29 | { 30 | if (null === $credentials) { 31 | $credentials = $this->adminCredentials(); 32 | } 33 | 34 | return new HttpClient($this->baseUri, $credentials, $connectTimeout, $options); 35 | } 36 | 37 | /** 38 | * @return UserCredentials 39 | */ 40 | protected function adminCredentials() 41 | { 42 | return new UserCredentials('admin', 'changeit'); 43 | } 44 | 45 | /** 46 | * @return EventData 47 | */ 48 | protected function fakeEvent() 49 | { 50 | return new EventData( 51 | $this->newUuid(), 52 | 'GetEventStore\\FakeEventType', 53 | ['a' => 'Test data'], 54 | ['b' => 'Test metadata'] 55 | ); 56 | } 57 | 58 | /** 59 | * @param int $size 60 | * 61 | * @return array 62 | */ 63 | protected function getEventDataSet($size = 3) 64 | { 65 | $events = []; 66 | for ($i = 1; $i <= $size; $i++) { 67 | $events[] = $this->fakeEvent(); 68 | } 69 | 70 | return $events; 71 | } 72 | 73 | /** 74 | * @param array $expectedEvents 75 | * @param EventRecordCollection $records 76 | * @param string $streamId 77 | * @param int $offset 78 | */ 79 | protected function assertEventDataMatchesEventRecords(array $expectedEvents, EventRecordCollection $records, string $streamId, int $offset = 0) 80 | { 81 | /** @var EventRecord $record */ 82 | foreach ($records as $key => $record) { 83 | /** @var EventData $data */ 84 | $data = array_shift($expectedEvents); 85 | $this->assertEquals($data->getData(), $record->getData()); 86 | $this->assertEquals($data->getType(), $record->getType()); 87 | $this->assertEquals($key + $offset, $record->getNumber()); 88 | $this->assertEquals($streamId, $record->getStreamId()); 89 | $this->assertEquals($data->getMetadata(), $record->getMetadata()); 90 | 91 | // todo: Add event id tests. 92 | // $this->assertEquals($data->getEventId(), $record->getEventId()); 93 | } 94 | 95 | $this->assertEmpty($expectedEvents); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | toString(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Unit/Http/AppendToStreamRequestFactoryTest.php: -------------------------------------------------------------------------------- 1 | newUuid(); 19 | $type1 = 'RayRutjes\GetEventStore\FakeEvent1'; 20 | $data1 = ['a' => 'test1']; 21 | $metadata1 = []; 22 | $event1 = new EventData($uuid1, $type1, $data1, $metadata1); 23 | 24 | $uuid2 = $this->newUuid(); 25 | $type2 = 'RayRutjes\GetEventStore\FakeEvent2'; 26 | $data2 = ['a' => 'test2']; 27 | $metadata2 = ['test' => 'value']; 28 | $event2 = new EventData($uuid2, $type2, $data2, $metadata2); 29 | 30 | $expectedBody = <<<'EOD' 31 | [ 32 | { 33 | "eventId": "%s", 34 | "eventType": "RayRutjes\\GetEventStore\\FakeEvent1", 35 | "data": {"a":"test1"}, 36 | "metadata":[] 37 | }, 38 | { 39 | "eventId": "%s", 40 | "eventType": "RayRutjes\\GetEventStore\\FakeEvent2", 41 | "data": {"a":"test2"}, 42 | "metadata": {"test":"value"} 43 | } 44 | ] 45 | EOD; 46 | $expectedBody = sprintf(str_replace([' ', "\n"], '', $expectedBody), $uuid1, $uuid2); 47 | 48 | $cut = new AppendToStreamRequestFactory( 49 | new StreamId('stream'), 50 | new ExpectedVersion(ExpectedVersion::ANY), 51 | EventDataCollection::fromArray([$event1, $event2]) 52 | ); 53 | $request = $cut->buildRequest(); 54 | $this->assertEquals('POST', $request->getMethod()); 55 | $this->assertEquals('streams/stream', $request->getUri()->getPath()); 56 | $this->assertEquals(ContentType::JSON_ES, $request->getHeaderLine(RequestHeader::CONTENT_TYPE)); 57 | $this->assertEmpty($request->getHeader(RequestHeader::EVENT_ID)); 58 | $this->assertEmpty($request->getHeader(RequestHeader::EVENT_TYPE)); 59 | $this->assertEquals(ExpectedVersion::ANY, $request->getHeaderLine(RequestHeader::EXPECTED_VERSION)); 60 | $this->assertEquals($expectedBody, $request->getBody()->getContents()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Unit/UserCredentialsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('login', $credentials->getLogin()); 14 | $this->assertEquals('password', $credentials->getPassword()); 15 | } 16 | 17 | /** 18 | * @dataProvider wrongCredentials 19 | * @expectedException \InvalidArgumentException 20 | */ 21 | public function testValidatesInput($login, $password) 22 | { 23 | new UserCredentials($login, $password); 24 | } 25 | 26 | public function wrongCredentials() 27 | { 28 | return [ 29 | ['', 'password'], 30 | ['login', ''], 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Unit/UuidTest.php: -------------------------------------------------------------------------------- 1 | newUuid(); 21 | $cut = new Uuid($uuid); 22 | $this->assertEquals($uuid, $cut->toString()); 23 | } 24 | } 25 | --------------------------------------------------------------------------------