Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/Exception/ExtensionStoreApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,4 +33,27 @@ public function getErrors(bool $withTrace = false): \Generator
];
}
}

/**
* @return array<array-key, mixed>
*/
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 : [];
}
}
111 changes: 111 additions & 0 deletions tests/Exception/ExtensionStoreApiExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace SwagExtensionStore\Tests\Exception;

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\NoSeekStream;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Shopware\Core\Framework\Log\Package;
use SwagExtensionStore\Exception\ExtensionStoreApiException;

#[Package('checkout')]
class ExtensionStoreApiExceptionTest extends TestCase
{
public function testErrorExposesTheCompleteStoreApiErrorBody(): void
{
$exception = new ExtensionStoreApiException($this->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<string, array{string}>
*/
public static function unusableBodyProvider(): \Generator
{
yield 'gateway returns an html error page instead of json' => ['<html>gateway timeout</html>'];
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<string, mixed>
*/
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),
);
}
}
Loading