diff --git a/composer.json b/composer.json index 52e12e6..4daf49f 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "ext-curl": "*", "ext-openssl": "*", "ext-sodium": "*", - "testcontainers/testcontainers": "1.0.8" + "testcontainers/testcontainers": "^1.0.8" }, "require-dev": { "phpstan/phpstan": "2.1.51", @@ -60,6 +60,7 @@ "allow-plugins": { "php-http/discovery": false }, + "bump-after-update": true, "sort-packages": true } } diff --git a/composer.lock b/composer.lock index 73ee951..f60e5fb 100644 --- a/composer.lock +++ b/composer.lock @@ -223,20 +223,19 @@ }, { "name": "jane-php/json-schema-runtime", - "version": "v7.10.4", + "version": "v7.11.2", "source": { "type": "git", "url": "https://github.com/janephp/json-schema-runtime.git", - "reference": "f1f96868836e7b9cfa45eb40d3afcd989f079a3a" + "reference": "447cba025ca2c9272daa9395668832d02ed60926" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/janephp/json-schema-runtime/zipball/f1f96868836e7b9cfa45eb40d3afcd989f079a3a", - "reference": "f1f96868836e7b9cfa45eb40d3afcd989f079a3a", + "url": "https://api.github.com/repos/janephp/json-schema-runtime/zipball/447cba025ca2c9272daa9395668832d02ed60926", + "reference": "447cba025ca2c9272daa9395668832d02ed60926", "shasum": "" }, "require": { - "ext-json": "*", "league/uri": "^6.7.2 || ^7.4", "php": "^8.1", "php-jsonpointer/php-jsonpointer": "^3.0 || ^4.0", @@ -279,22 +278,22 @@ ], "description": "Jane runtime Library", "support": { - "source": "https://github.com/janephp/json-schema-runtime/tree/v7.10.4" + "source": "https://github.com/janephp/json-schema-runtime/tree/v7.11.2" }, - "time": "2026-01-12T19:55:34+00:00" + "time": "2026-03-26T17:01:00+00:00" }, { "name": "jane-php/open-api-runtime", - "version": "v7.10.4", + "version": "v7.11.2", "source": { "type": "git", "url": "https://github.com/janephp/open-api-runtime.git", - "reference": "825670a6cbd0e2b8246af0c1b15ce4b986318942" + "reference": "2afe544405a61d4254baf0bc99c299b4dffdf51e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/janephp/open-api-runtime/zipball/825670a6cbd0e2b8246af0c1b15ce4b986318942", - "reference": "825670a6cbd0e2b8246af0c1b15ce4b986318942", + "url": "https://api.github.com/repos/janephp/open-api-runtime/zipball/2afe544405a61d4254baf0bc99c299b4dffdf51e", + "reference": "2afe544405a61d4254baf0bc99c299b4dffdf51e", "shasum": "" }, "require": { @@ -342,9 +341,9 @@ ], "description": "Jane OpenAPI Runtime Library, dependencies and utility class for a library generated by jane/openapi", "support": { - "source": "https://github.com/janephp/open-api-runtime/tree/v7.10.4" + "source": "https://github.com/janephp/open-api-runtime/tree/v7.11.2" }, - "time": "2026-01-12T19:55:34+00:00" + "time": "2026-03-26T13:31:24+00:00" }, { "name": "league/uri", @@ -1332,16 +1331,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { @@ -1378,7 +1377,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" }, "funding": [ { @@ -1398,20 +1397,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b38026df55197f9e39a44f3215788edf83187b80" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", - "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { @@ -1449,7 +1448,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -1469,20 +1468,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -1532,7 +1531,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -1552,20 +1551,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -1617,7 +1616,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -1637,20 +1636,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -1701,7 +1700,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -1721,20 +1720,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -1781,7 +1780,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -1801,20 +1800,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -1861,7 +1860,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -1881,20 +1880,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -1926,7 +1925,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -1946,20 +1945,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b" + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", + "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455", + "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455", "shasum": "" }, "require": { @@ -2030,7 +2029,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.7" + "source": "https://github.com/symfony/serializer/tree/v7.4.8" }, "funding": [ { @@ -2050,7 +2049,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-30T21:34:42+00:00" }, { "name": "symfony/translation-contracts", @@ -2136,16 +2135,16 @@ }, { "name": "symfony/validator", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "3a1a460a9f8c5e5611e15c52c4baa5a62fa3c203" + "reference": "8f73cbddae916756f319b3e195088da216f0f12f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/3a1a460a9f8c5e5611e15c52c4baa5a62fa3c203", - "reference": "3a1a460a9f8c5e5611e15c52c4baa5a62fa3c203", + "url": "https://api.github.com/repos/symfony/validator/zipball/8f73cbddae916756f319b3e195088da216f0f12f", + "reference": "8f73cbddae916756f319b3e195088da216f0f12f", "shasum": "" }, "require": { @@ -2216,7 +2215,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.7" + "source": "https://github.com/symfony/validator/tree/v7.4.8" }, "funding": [ { @@ -2236,20 +2235,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T11:10:17+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", "shasum": "" }, "require": { @@ -2292,7 +2291,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.8" }, "funding": [ { @@ -2312,7 +2311,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "testcontainers/testcontainers", diff --git a/src/Client.php b/src/Client.php index abb5345..51d3d50 100644 --- a/src/Client.php +++ b/src/Client.php @@ -8,6 +8,7 @@ use RuntimeException; use Thenativeweb\Eventsourcingdb\Stream\HttpClient; use Thenativeweb\Eventsourcingdb\Stream\NdJson; +use Thenativeweb\Eventsourcingdb\Stream\Response; final readonly class Client { @@ -30,17 +31,9 @@ public function abortIn(float $seconds): void public function ping(): void { $response = $this->httpClient->get('/api/v1/ping'); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to ping, got HTTP status code '%d', expected '200'", - $status - )); - } + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to ping'); try { $data = $response->getStream()->getJsonData(); @@ -63,17 +56,9 @@ public function verifyApiToken(): void '/api/v1/verify-api-token', $this->apiToken, ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to verify API token, got HTTP status code '%d', expected '200'", - $status - )); - } + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to verify API token'); try { $data = $response->getStream()->getJsonData(); @@ -104,17 +89,9 @@ public function writeEvents(array $events, array $preconditions = []): array $this->apiToken, $requestBody, ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to write events, got HTTP status code '%d', expected '200'", - $status - )); - } + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to write events'); try { $data = $response->getStream()->getJsonData(); @@ -159,17 +136,9 @@ public function readEvents(string $subject, ReadEventsOptions $readEventsOptions 'options' => $readEventsOptions, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to read events, got HTTP status code '%d', expected '200'", - $status - )); - } + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to read events'); foreach (NdJson::readStream($response->getStream()) as $eventLine) { switch ($eventLine->type) { @@ -210,17 +179,9 @@ public function runEventQlQuery(string $query): iterable 'query' => $query, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to run EventQL query, got HTTP status code '%d', expected '200'", - $status - )); - } + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to run EventQL query'); foreach (NdJson::readStream($response->getStream()) as $eventLine) { switch ($eventLine->type) { @@ -249,16 +210,9 @@ public function observeEvents(string $subject, ObserveEventsOptions $observeEven 'options' => $observeEventsOptions, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to observe events, got HTTP status code '%d', expected '200'", - $status - )); - } + + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to observe events'); foreach (NdJson::readStream($response->getStream()) as $eventLine) { switch ($eventLine->type) { @@ -302,16 +256,9 @@ public function registerEventSchema(string $eventType, array $schema): void 'schema' => $schema, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to register event schema, got HTTP status code '%d', expected '200'", - $status - )); - } + + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to register event schema'); } public function readSubjects(string $baseSubject): iterable @@ -323,16 +270,9 @@ public function readSubjects(string $baseSubject): iterable 'baseSubject' => $baseSubject, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to read subjects, got HTTP status code '%d', expected '200'", - $status - )); - } + + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to read subjects'); foreach (NdJson::readStream($response->getStream()) as $eventLine) { switch ($eventLine->type) { @@ -357,16 +297,9 @@ public function readEventTypes(): iterable '/api/v1/read-event-types', $this->apiToken, ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to read event types, got HTTP status code '%d', expected '200'", - $status - )); - } + + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to read event types'); foreach (NdJson::readStream($response->getStream()) as $eventLine) { switch ($eventLine->type) { @@ -398,16 +331,9 @@ public function readEventType(string $eventType): EventType 'eventType' => $eventType, ], ); - if (!$this->isValidServerHeader($response)) { - throw new RuntimeException('Server must be EventSourcingDB.'); - } - $status = $response->getStatusCode(); - if ($status !== 200) { - throw new RuntimeException(sprintf( - "Failed to read event type, got HTTP status code '%d', expected '200'", - $status - )); - } + + $this->throwIfNotValidServerHeader($response); + $this->throwIfNotSuccessStatusCode($response, 'Failed to read event type'); try { $data = $response->getStream()->getJsonData(); @@ -426,13 +352,31 @@ public function readEventType(string $eventType): EventType ); } - private function isValidServerHeader(\Thenativeweb\Eventsourcingdb\Stream\Response $response): bool + private function throwIfNotValidServerHeader(Response $response): void { $serverHeader = $response->getHeader('Server'); if ($serverHeader === []) { - return false; + throw new RuntimeException('Server Header is empty.'); + } + + if (!str_starts_with($serverHeader[0], 'EventSourcingDB/')) { + throw new RuntimeException('Server must be EventSourcingDB.'); + } + } + + private function throwIfNotSuccessStatusCode(Response $response, string $scope): void + { + $status = $response->getStatusCode(); + if ($status !== 200) { + throw new RuntimeException( + message: sprintf( + '%s, %s', + $scope, + $response->getStream()->getContents(), + ), + code: $status, + ); } - return str_starts_with($serverHeader[0], 'EventSourcingDB/'); } } diff --git a/src/CloudEvent.php b/src/CloudEvent.php index e499477..dd05a7c 100644 --- a/src/CloudEvent.php +++ b/src/CloudEvent.php @@ -80,8 +80,12 @@ public function verifySignature(string $verificationKey): void throw new RuntimeException('Failed to decode signature from hex.'); } - if ($signatureBytes === '') { - throw new RuntimeException('Signature must not be empty.'); + if (strlen($signatureBytes) !== SODIUM_CRYPTO_SIGN_BYTES) { + throw new RuntimeException(sprintf( + 'Signature has an invalid length: expected %d bytes, got %d bytes.', + SODIUM_CRYPTO_SIGN_BYTES, + strlen($signatureBytes), + )); } $isSignatureValid = sodium_crypto_sign_verify_detached($signatureBytes, $this->hash, $verificationKey); diff --git a/src/SigningKey.php b/src/SigningKey.php index 016b4a4..50a2961 100644 --- a/src/SigningKey.php +++ b/src/SigningKey.php @@ -14,9 +14,9 @@ final class SigningKey { - public string $privateKeyPem; - public string $publicKeyPem; - public Ed25519 $ed25519; + public readonly string $privateKeyPem; + public readonly string $publicKeyPem; + public readonly Ed25519 $ed25519; public function __construct() { @@ -25,7 +25,7 @@ public function __construct() $secretKey = sodium_crypto_sign_secretkey($keypair); $privateKey = substr($secretKey, 0, 32); - $publicKey = substr($secretKey, 32, 32); + $publicKey = sodium_crypto_sign_publickey($keypair); $this->privateKeyPem = $this->generatePem($privateKey, 'PRIVATE KEY'); $this->publicKeyPem = $this->generatePem($publicKey, 'PUBLIC KEY'); diff --git a/tests/CloudEventSignatureTest.php b/tests/CloudEventSignatureTest.php index febe67f..b7c13ae 100755 --- a/tests/CloudEventSignatureTest.php +++ b/tests/CloudEventSignatureTest.php @@ -26,6 +26,88 @@ protected function tearDown(): void parent::tearDown(); } + public function testThrowsAnErrorIfVerificationKeyIsEmpty(): void + { + $imageVersion = getImageVersionFromDockerfile(); + $container = (new Container()) + ->withImageTag($imageVersion) + ->withSigningKey(); + $container->start(); + $this->container = $container; + + $client = $container->getClient(); + + $writtenEvents = $client->writeEvents([ + new EventCandidate( + source: 'https://www.eventsourcingdb.io', + subject: '/test', + type: 'io.eventsourcingdb.test', + data: [ + 'value' => 23, + ], + ), + ]); + + $this->assertCount(1, $writtenEvents); + $this->assertNotNull($writtenEvents[0]->signature); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Verification key must not be empty.'); + + $writtenEvents[0]->verifySignature(''); + } + + public function testThrowsAnErrorIfSignatureHasInvalidLength(): void + { + $imageVersion = getImageVersionFromDockerfile(); + $container = (new Container()) + ->withImageTag($imageVersion) + ->withSigningKey(); + $container->start(); + $this->container = $container; + + $client = $container->getClient(); + + $writtenEvents = $client->writeEvents([ + new EventCandidate( + source: 'https://www.eventsourcingdb.io', + subject: '/test', + type: 'io.eventsourcingdb.test', + data: [ + 'value' => 23, + ], + ), + ]); + + $this->assertCount(1, $writtenEvents); + + $writtenEvent = $writtenEvents[0]; + + $this->assertNotNull($writtenEvent->signature); + + $tamperedCloudEvent = new CloudEvent( + specVersion: $writtenEvent->specVersion, + id: $writtenEvent->id, + time: $writtenEvent->time, + timeFromServer: $this->getPropertyValue($writtenEvent, 'timeFromServer'), + source: $writtenEvent->source, + subject: $writtenEvent->subject, + type: $writtenEvent->type, + dataContentType: $writtenEvent->dataContentType, + data: $writtenEvent->data, + hash: $writtenEvent->hash, + predecessorHash: $writtenEvent->predecessorHash, + traceParent: $writtenEvent->traceParent, + traceState: $writtenEvent->traceState, + signature: 'esdb:signature:v1:deadbeef', + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Signature has an invalid length: expected 64 bytes, got 4 bytes.'); + + $tamperedCloudEvent->verifySignature($container->getVerificationKey()); + } + public function testThrowsAnErrorIfTheSignatureIsNull(): void { $imageVersion = getImageVersionFromDockerfile(); diff --git a/tests/CloudEventTest.php b/tests/CloudEventTest.php index b30e852..b3fded1 100755 --- a/tests/CloudEventTest.php +++ b/tests/CloudEventTest.php @@ -79,6 +79,8 @@ public function testThrowsAnErrorIfTheEventHashIsInvalid(): void ); $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to verify hash'); + $tamperedCloudEvent->verifyHash(); } } diff --git a/tests/PingTest.php b/tests/PingTest.php index b118b68..63f5e97 100644 --- a/tests/PingTest.php +++ b/tests/PingTest.php @@ -24,6 +24,8 @@ public function testFailsWhenServerIsUnreachable(): void $client = new Client("http://non-existent-host:{$port}", $this->container->getApiToken()); $this->expectException(\Throwable::class); + $this->expectExceptionMessage('Internal HttpClient: cURL handle execution failed with error: Could not resolve host: non-existent-host'); + $client->ping(); } } diff --git a/tests/ReadEventTypeTest.php b/tests/ReadEventTypeTest.php index a3d56b8..839a846 100755 --- a/tests/ReadEventTypeTest.php +++ b/tests/ReadEventTypeTest.php @@ -5,6 +5,7 @@ namespace Thenativeweb\Eventsourcingdb\Tests; use PHPUnit\Framework\TestCase; +use RuntimeException; use Thenativeweb\Eventsourcingdb\EventCandidate; use Thenativeweb\Eventsourcingdb\EventType; use Thenativeweb\Eventsourcingdb\Tests\Trait\ClientTestTrait; @@ -15,14 +16,18 @@ final class ReadEventTypeTest extends TestCase public function testFailsIfTheEventTypeDoesNotExist(): void { - $this->expectExceptionMessage("Failed to read event type, got HTTP status code '404', expected '200'"); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to read event type, event type 'non.existent.eventType' not found"); + $this->expectExceptionCode(404); $this->client->readEventType('non.existent.eventType'); } public function testFailsIfTheEventTypeIsMalformed(): void { - $this->expectExceptionMessage("Failed to read event type, got HTTP status code '400', expected '200'"); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to read event type, invalid event type: 'malformed.eventType.'"); + $this->expectExceptionCode(400); $this->client->readEventType('malformed.eventType.'); } diff --git a/tests/RegisterEventSchemaTest.php b/tests/RegisterEventSchemaTest.php index 3e58074..237d520 100644 --- a/tests/RegisterEventSchemaTest.php +++ b/tests/RegisterEventSchemaTest.php @@ -48,7 +48,8 @@ public function testThrowsAnErrorIfAnEventSchemaIsAlreadyRegistered(): void $this->client->registerEventSchema($eventType, $schema); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Failed to register event schema, got HTTP status code '409', expected '200'"); + $this->expectExceptionMessage('Failed to register event schema, schema conflict: schema already exists'); + $this->expectExceptionCode(409); $this->client->registerEventSchema($eventType, $schema); } diff --git a/tests/VerifyApiTokenTest.php b/tests/VerifyApiTokenTest.php index 3a15a11..cd3507c 100644 --- a/tests/VerifyApiTokenTest.php +++ b/tests/VerifyApiTokenTest.php @@ -24,7 +24,11 @@ public function testThrowsAnErrorIfTheTokenIsInvalid(): void $baseUrl = $this->container->getBaseUrl(); $apiToken = $this->container->getApiToken() . '-invalid'; $client = new Client($baseUrl, $apiToken); + $this->expectException(\Throwable::class); + $this->expectExceptionMessage('Failed to verify API token, unauthorized'); + $this->expectExceptionCode(401); + $client->verifyApiToken(); } } diff --git a/tests/WriteEventsTest.php b/tests/WriteEventsTest.php index 783b740..75e6b8f 100644 --- a/tests/WriteEventsTest.php +++ b/tests/WriteEventsTest.php @@ -95,7 +95,9 @@ public function testSupportsTheIsSubjectPristinePrecondition(): void ], ); - $this->expectExceptionMessage("Failed to write events, got HTTP status code '409', expected '200'"); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to write events, state conflict: precondition failed'); + $this->expectExceptionCode(409); $this->client->writeEvents( [ @@ -118,7 +120,9 @@ public function testRejectsWritingToEmptySubjectWhenUsingTheIsSubjectPopulatedPr ], ); - $this->expectExceptionMessage("Failed to write events, got HTTP status code '409', expected '200'"); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to write events, state conflict: precondition failed'); + $this->expectExceptionCode(409); $this->client->writeEvents( [ @@ -193,7 +197,10 @@ public function testSupportsTheIsSubjectOnEventIdPrecondition(): void ], ); - $this->expectExceptionMessage("Failed to write events, got HTTP status code '409', expected '200'"); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to write events, state conflict: precondition failed'); + $this->expectExceptionCode(409); + $this->client->writeEvents( [ $secondEvent, @@ -228,7 +235,10 @@ public function testSupportsTheIsEventQlQueryTruePrecondition(): void ], ); - $this->expectExceptionMessage("Failed to write events, got HTTP status code '409', expected '200'"); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to write events, state conflict: precondition failed'); + $this->expectExceptionCode(409); + $this->client->writeEvents( [ $secondEvent,