178 lines
5.9 KiB
PHP
178 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Shopware;
|
|
|
|
use RuntimeException;
|
|
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
final readonly class StoreApiClient
|
|
{
|
|
public function __construct(
|
|
private HttpClientInterface $httpClient,
|
|
private string $baseUrl,
|
|
private string $salesChannelAccessKey,
|
|
private int $timeoutSeconds = 5,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @throws TransportExceptionInterface
|
|
* @throws ServerExceptionInterface
|
|
* @throws RedirectionExceptionInterface
|
|
* @throws ClientExceptionInterface
|
|
* @throws StoreApiException
|
|
*/
|
|
public function searchProducts(array $criteria): array
|
|
{
|
|
$url = rtrim($this->baseUrl, '/') . '/store-api/search';
|
|
$sanitizedCriteria = $this->sanitizeValue($criteria);
|
|
|
|
try {
|
|
$body = json_encode(
|
|
$sanitizedCriteria,
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
|
|
);
|
|
} catch (\JsonException $e) {
|
|
throw new RuntimeException('Failed to encode Store API criteria as valid JSON.', 0, $e);
|
|
}
|
|
|
|
$response = $this->httpClient->request('POST', $url, [
|
|
'headers' => [
|
|
'Content-Type' => 'application/json; charset=utf-8',
|
|
'Accept' => 'application/json',
|
|
'sw-access-key' => $this->salesChannelAccessKey,
|
|
],
|
|
'body' => $body,
|
|
// Keep Shopware calls bounded. During SSE responses the browser only
|
|
// receives data between blocking HTTP calls, so long Store API waits
|
|
// can look like a broken stream to proxies or the browser.
|
|
'timeout' => $this->timeoutSeconds,
|
|
'max_duration' => max(1, $this->timeoutSeconds + 1),
|
|
]);
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
$content = $response->getContent(false);
|
|
$content = $this->sanitizeString($content);
|
|
|
|
if ($statusCode < 200 || $statusCode >= 300) {
|
|
throw $this->buildHttpFailure($statusCode, $content);
|
|
}
|
|
|
|
$data = json_decode($content, true);
|
|
|
|
if (!is_array($data)) {
|
|
throw new StoreApiException(
|
|
'Shopware Store API returned invalid JSON.',
|
|
$statusCode,
|
|
true,
|
|
$this->containsUtf8FailureSignal($content),
|
|
true
|
|
);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function buildHttpFailure(int $statusCode, string $content): StoreApiException
|
|
{
|
|
$preview = mb_substr(trim($content), 0, 1000);
|
|
$utf8Failure = $this->containsUtf8FailureSignal($preview);
|
|
$normalizedPreview = mb_strtolower($preview, 'UTF-8');
|
|
$accessKeyFailure = $statusCode === 401
|
|
|| $statusCode === 403
|
|
|| str_contains($preview, 'FRAMEWORK__API_INVALID_ACCESS_KEY')
|
|
|| str_contains($normalizedPreview, 'access key is invalid');
|
|
$serverFailure = $statusCode >= 500 || $accessKeyFailure;
|
|
|
|
$hint = '';
|
|
|
|
if ($accessKeyFailure) {
|
|
$hint = ' Hint: The configured Shopware Sales Channel access key is invalid or does not match the Store API endpoint.';
|
|
} elseif ($utf8Failure) {
|
|
$hint = ' Hint: The request body was valid JSON; this Shopware error usually means Shopware failed while encoding response/product data as UTF-8.';
|
|
}
|
|
|
|
return new StoreApiException(
|
|
sprintf(
|
|
'Shopware Store API request failed with status %d. Response: %s%s',
|
|
$statusCode,
|
|
$preview,
|
|
$hint
|
|
),
|
|
$statusCode,
|
|
$serverFailure,
|
|
$utf8Failure,
|
|
$serverFailure || $utf8Failure || $accessKeyFailure
|
|
);
|
|
}
|
|
|
|
private function containsUtf8FailureSignal(string $content): bool
|
|
{
|
|
$normalized = mb_strtolower($content, 'UTF-8');
|
|
|
|
return str_contains($normalized, 'malformed utf-8')
|
|
|| str_contains($normalized, 'malformed utf8')
|
|
|| str_contains($normalized, 'invalid utf-8')
|
|
|| str_contains($normalized, 'invalid utf8')
|
|
|| str_contains($normalized, 'possibly incorrectly encoded');
|
|
}
|
|
|
|
private function sanitizeValue(mixed $value): mixed
|
|
{
|
|
if (is_array($value)) {
|
|
$out = [];
|
|
|
|
foreach ($value as $key => $item) {
|
|
$out[$key] = $this->sanitizeValue($item);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
if (!is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
return $this->sanitizeString($value);
|
|
}
|
|
|
|
private function sanitizeString(string $value): string
|
|
{
|
|
if (preg_match('//u', $value) !== 1) {
|
|
if (function_exists('mb_convert_encoding')) {
|
|
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
|
}
|
|
|
|
if (preg_match('//u', $value) !== 1 && function_exists('iconv')) {
|
|
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
|
|
|
|
if (is_string($converted)) {
|
|
$value = $converted;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (preg_match('//u', $value) !== 1) {
|
|
return '';
|
|
}
|
|
|
|
if (class_exists('Normalizer')) {
|
|
$normalized = \Normalizer::normalize($value, \Normalizer::FORM_C);
|
|
|
|
if (is_string($normalized)) {
|
|
$value = $normalized;
|
|
}
|
|
}
|
|
|
|
// Keep tab/newline/carriage-return, strip other control characters that can
|
|
// make downstream JSON handling and logs harder to diagnose.
|
|
return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value);
|
|
}
|
|
}
|