apiVersion)) { $queryParams['v'] = $this->apiVersion; } $this->logger->debug('Sending API request', [ 'method' => $method, 'url' => $this->baseUrl . $uri, 'body' => $body, ]); $fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams); $request = $this->requestFactory ->createRequest($method, $fullUrl) ->withHeader('Authorization', $this->accessToken); if (!empty($body)) { try { $payload = json_encode($body, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new SerializationException('Failed to encode request body to JSON.', 0, $e); } $stream = $this->streamFactory->createStream($payload); $request = $request ->withBody($stream) ->withHeader('Content-Type', 'application/json; charset=utf-8'); } try { $response = $this->httpClient->sendRequest($request); } catch (ClientExceptionInterface $e) { // This catches network errors, DNS failures, timeouts, etc. $this->logger->error('Network exception during API request', [ 'message' => $e->getMessage(), 'exception' => $e, ]); throw new NetworkException($e->getMessage(), $e->getCode(), $e); } $this->handleErrorResponse($response); $responseBody = (string)$response->getBody(); $this->logger->debug('Received API response', [ 'status' => $response->getStatusCode(), 'body' => $responseBody, ]); // Handle successful but empty responses (e.g., from DELETE endpoints) if (empty($responseBody)) { // The API spec often returns {"success": true}, so we can simulate that // for consistency if the body is truly empty. return ['success' => true]; } try { return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new SerializationException('Failed to decode API response JSON.', 0, $e); } } /** * @inheritDoc */ public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string { $boundary = '--------------------------' . microtime(true); $bodyStream = $this->streamFactory->createStream(); $bodyStream->write("--$boundary\r\n"); $bodyStream->write("Content-Disposition: form-data; name=\"data\"; filename=\"{$fileName}\"\r\n"); $bodyStream->write("Content-Type: application/octet-stream\r\n\r\n"); if (is_resource($fileContents)) { $bodyStream->write((string)stream_get_contents($fileContents)); } else { $bodyStream->write((string)$fileContents); } $bodyStream->write("\r\n"); $bodyStream->write("--$boundary--\r\n"); $request = $this->requestFactory ->createRequest('POST', $uri) ->withHeader('Content-Type', 'multipart/form-data; boundary=' . $boundary) ->withHeader('Authorization', $this->accessToken) ->withBody($bodyStream); try { $response = $this->httpClient->sendRequest($request); } catch (ClientExceptionInterface $e) { throw new NetworkException($e->getMessage(), $e->getCode(), $e); } $this->handleErrorResponse($response); return (string)$response->getBody(); } /** * @inheritDoc */ public function resumableUpload( string $uploadUrl, mixed $fileResource, string $fileName, int $fileSize, int $chunkSize = 1048576, ): string { if (!is_resource($fileResource) || get_resource_type($fileResource) !== 'stream') { throw new InvalidArgumentException('fileResource must be a valid stream resource.'); } // @phpstan-ignore-next-line if ($fileSize <= 0) { throw new InvalidArgumentException('File size must be greater than 0.'); } $startByte = 0; $finalResponseBody = ''; while (!feof($fileResource)) { $chunk = fread($fileResource, $chunkSize); if ($chunk === false) { // @codeCoverageIgnoreStart throw new RuntimeException('Failed to read chunk from file stream.'); // @codeCoverageIgnoreEnd } $chunkLength = strlen($chunk); if ($chunkLength === 0) { break; } $endByte = $startByte + $chunkLength - 1; $chunkStream = $this->streamFactory->createStream($chunk); $request = $this->requestFactory->createRequest('POST', $uploadUrl) ->withBody($chunkStream) ->withHeader('Content-Type', 'application/octet-stream') ->withHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"') ->withHeader('Content-Range', "bytes {$startByte}-{$endByte}/{$fileSize}") ->withHeader('Authorization', $this->accessToken); try { $response = $this->httpClient->sendRequest($request); } catch (ClientExceptionInterface $e) { throw new NetworkException($e->getMessage(), $e->getCode(), $e); } $this->handleErrorResponse($response); // The final response might contain the retval $finalResponseBody = (string)$response->getBody(); $startByte += $chunkLength; } // According to docs, for video/audio the token is sent separately, // and the upload response contains 'retval'. We return the body of the last response. return $finalResponseBody; } /** * Checks the response for an error status code and throws a corresponding typed exception. * * @throws ClientApiException */ private function handleErrorResponse(ResponseInterface $response): void { $statusCode = $response->getStatusCode(); // 2xx codes are considered successful. if ($statusCode >= 200 && $statusCode < 300) { return; } $responseBody = (string)$response->getBody(); $data = json_decode($responseBody, true) ?? []; $errorCode = $data['code'] ?? 'unknown'; $errorMessage = $data['message'] ?? 'An unknown error occurred.'; $this->logger->error('API error response received', [ 'status' => $statusCode, 'body' => $responseBody, ]); throw match ($statusCode) { 401 => new UnauthorizedException($errorMessage, $errorCode, $response), 403 => new ForbiddenException($errorMessage, $errorCode, $response), 404 => new NotFoundException($errorMessage, $errorCode, $response), 405 => new MethodNotAllowedException($errorMessage, $errorCode, $response), 429 => new RateLimitExceededException($errorMessage, $errorCode, $response), default => new ClientApiException($errorMessage, $errorCode, $response, $statusCode), }; } }