From e4bfe0fdd396272f89ebf43d0b7b068125c5750b Mon Sep 17 00:00:00 2001 From: Jannik Eberhard Date: Wed, 16 Sep 2026 08:25:28 +0200 Subject: [PATCH] ECTO-2761 - fix client exception body parsing --- src/Exception/ExtensionStoreApiException.php | 30 ++++- .../ExtensionStoreApiExceptionTest.php | 111 ++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/Exception/ExtensionStoreApiExceptionTest.php diff --git a/src/Exception/ExtensionStoreApiException.php b/src/Exception/ExtensionStoreApiException.php index 0c362c16..5f5f7be8 100644 --- a/src/Exception/ExtensionStoreApiException.php +++ b/src/Exception/ExtensionStoreApiException.php @@ -17,14 +17,9 @@ public function __construct(ClientException $exception) { parent::__construct($exception); - try { - $data = json_decode($exception->getResponse()->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - } + $data = $this->getData($exception); $this->apiCode = $data['code'] ?? ''; $this->description = $data['description'] ?? ''; - $this->title = $data['title'] ?? ''; - $this->documentationLink = $data['documentationLink'] ?? ''; } public function getErrors(bool $withTrace = false): \Generator @@ -38,4 +33,27 @@ public function getErrors(bool $withTrace = false): \Generator ]; } } + + /** + * @return array + */ + private function getData(ClientException $exception): array + { + $body = $exception->getResponse()->getBody(); + + // The parent constructor already read the body to its end and left the stream at EOF, + // so reading it again without rewinding yields an empty string. + if ($body->isSeekable()) { + $body->rewind(); + } + + try { + $data = json_decode($body->getContents(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + // A body may be valid JSON without being an object, e.g. a bare string or number. + return \is_array($data) ? $data : []; + } } diff --git a/tests/Exception/ExtensionStoreApiExceptionTest.php b/tests/Exception/ExtensionStoreApiExceptionTest.php new file mode 100644 index 00000000..934de52b --- /dev/null +++ b/tests/Exception/ExtensionStoreApiExceptionTest.php @@ -0,0 +1,111 @@ +createClientException((string) json_encode([ + 'code' => 'STORE__EXTENSION_NOT_PURCHASABLE', + 'title' => 'Extension cannot be purchased', + 'description' => 'The extension is not available for your shop.', + 'documentationLink' => 'https://docs.shopware.com/store-error', + ], \JSON_THROW_ON_ERROR))); + + $error = $this->firstError($exception); + + static::assertSame('STORE__EXTENSION_NOT_PURCHASABLE', $error['apiCode']); + static::assertSame('Extension cannot be purchased', $error['title']); + static::assertSame('The extension is not available for your shop.', $error['description']); + static::assertSame('The extension is not available for your shop.', $error['detail']); + static::assertIsArray($error['meta']); + static::assertSame('https://docs.shopware.com/store-error', $error['meta']['documentationLink']); + } + + #[DataProvider('unusableBodyProvider')] + public function testErrorFallsBackToEmptyValuesOnAnUnusableBody(string $body): void + { + $exception = new ExtensionStoreApiException($this->createClientException($body)); + + $error = $this->firstError($exception); + + static::assertSame('', $error['apiCode']); + static::assertSame('', $error['title']); + static::assertSame('', $error['description']); + static::assertSame('Store is not reachable', $error['detail']); + static::assertIsArray($error['meta']); + static::assertSame('', $error['meta']['documentationLink']); + } + + /** + * @return \Generator + */ + public static function unusableBodyProvider(): \Generator + { + yield 'gateway returns an html error page instead of json' => ['gateway timeout']; + yield 'response has no body at all' => ['']; + yield 'body is valid json but decodes to a string' => ['"just a string"']; + yield 'body is valid json but decodes to a number' => ['42']; + yield 'body is valid json but decodes to null' => ['null']; + yield 'body is a json list instead of an error object' => ['[1, 2, 3]']; + } + + public function testErrorKeepsTheFieldsTheParentParsedWhenTheBodyCannotBeReadTwice(): void + { + $body = new NoSeekStream(Utils::streamFor((string) json_encode([ + 'code' => 'STORE__EXTENSION_NOT_PURCHASABLE', + 'title' => 'Extension cannot be purchased', + 'description' => 'The extension is not available for your shop.', + 'documentationLink' => 'https://docs.shopware.com/store-error', + ], \JSON_THROW_ON_ERROR))); + + $exception = new ExtensionStoreApiException(new ClientException( + 'Store is not reachable', + new Request('GET', '/swplatform/extensionstore/baskets'), + new Response(400, [], $body), + )); + + $error = $this->firstError($exception); + + static::assertSame('Extension cannot be purchased', $error['title']); + static::assertIsArray($error['meta']); + static::assertSame('https://docs.shopware.com/store-error', $error['meta']['documentationLink']); + static::assertSame('The extension is not available for your shop.', $error['detail']); + static::assertSame('', $error['apiCode']); + static::assertSame('', $error['description']); + } + + /** + * The parent declares a sealed error shape, so the extra fields this exception adds are invisible + * to static analysis when read straight off the generator. + * + * @return array + */ + private function firstError(ExtensionStoreApiException $exception): array + { + return $exception->getErrors()->current(); + } + + private function createClientException(string $body): ClientException + { + return new ClientException( + 'Store is not reachable', + new Request('GET', '/swplatform/extensionstore/baskets'), + new Response(400, [], $body), + ); + } +}