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); } }