From 3127f86c547b9ba51a7a156bafbf8ec42e5720ed Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 25 Aug 2025 23:11:06 +0300 Subject: [PATCH] Added resumable upload --- src/Api.php | 34 ++++++++++++++++++-- src/Client.php | 64 +++++++++++++++++++++++++++++++++++++- src/ClientApiInterface.php | 26 +++++++++++++++- tests/ApiTest.php | 18 +++++------ tests/ClientTest.php | 8 ++--- 5 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/Api.php b/src/Api.php index d4995f2..8d7e242 100644 --- a/src/Api.php +++ b/src/Api.php @@ -75,6 +75,8 @@ class Api private const string ACTION_ANSWERS = '/answers'; private const string ACTION_VIDEO_DETAILS = '/videos/%s'; + private const int RESUMABLE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10 MB + private readonly ClientApiInterface $client; private readonly ModelFactory $modelFactory; @@ -443,6 +445,33 @@ class Api ); } + /** + * @param string $uploadUrl The target URL for the upload. + * @param resource $fileHandle A stream resource pointing to the file. + * @param string $fileName The desired file name for the upload. + * + * @return string The body of the final response from the server. + * @throws ClientApiException + * @throws NetworkException + * @throws SerializationException + * @throws RuntimeException + */ + public function uploadFile(string $uploadUrl, mixed $fileHandle, string $fileName): string + { + $stat = fstat($fileHandle); + if (!is_array($stat)) { + throw new RuntimeException('File handle is not a valid resource.'); + } + + rewind($fileHandle); + + if ($stat['size'] < self::RESUMABLE_UPLOAD_THRESHOLD_BYTES) { + return $this->client->multipartUpload($uploadUrl, $fileHandle, $fileName); + } + + return $this->client->resumableUpload($uploadUrl, $fileHandle, $fileName, $stat['size']); + } + /** * A simplified method for uploading a file and getting the resulting attachment object. * @@ -479,7 +508,8 @@ class Api "API did not return a pre-upload token for type '$type->value'." ); } - $this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath)); + + $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath)); fclose($fileHandle); return match ($type) { @@ -489,7 +519,7 @@ class Api } // For images and files, the token is in the response *after* the upload. - $responseBody = $this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath)); + $responseBody = $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath)); fclose($fileHandle); try { diff --git a/src/Client.php b/src/Client.php index 5b4adae..d7edadd 100644 --- a/src/Client.php +++ b/src/Client.php @@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use RuntimeException; /** * The low-level HTTP client responsible for communicating with the Max Bot API. @@ -122,7 +123,7 @@ final readonly class Client implements ClientApiInterface /** * @inheritDoc */ - public function upload(string $uri, mixed $fileContents, string $fileName): string + public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string { $boundary = '--------------------------' . microtime(true); $bodyStream = $this->streamFactory->createStream(); @@ -155,6 +156,67 @@ final readonly class Client implements ClientApiInterface 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) { + throw new RuntimeException('Failed to read chunk from file stream.'); + } + + $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}"); + + 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. * diff --git a/src/ClientApiInterface.php b/src/ClientApiInterface.php index e476368..34591fe 100644 --- a/src/ClientApiInterface.php +++ b/src/ClientApiInterface.php @@ -7,6 +7,7 @@ namespace BushlanovDev\MaxMessengerBot; use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException; use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; +use RuntimeException; interface ClientApiInterface { @@ -37,5 +38,28 @@ interface ClientApiInterface * @throws NetworkException * @throws SerializationException */ - public function upload(string $uri, mixed $fileContents, string $fileName): string; + public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string; + + /** + * Uploads a file in chunks using the resumable upload method. + * The caller is responsible for opening and closing the file resource. + * + * @param string $uploadUrl The target URL for the upload. + * @param resource $fileResource A stream resource pointing to the file. + * @param string $fileName The desired file name for the upload. + * @param int<1, max> $fileSize The total size of the file in bytes. + * @param int<1, max> $chunkSize The size of each chunk in bytes. + * + * @return string The body of the final response from the server. + * @throws NetworkException + * @throws ClientApiException + * @throws RuntimeException + */ + public function resumableUpload( + string $uploadUrl, + $fileResource, + string $fileName, + int $fileSize, + int $chunkSize = 1048576, + ): string; } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 73d6a2c..4e4e1f7 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -461,7 +461,7 @@ final class ApiTest extends TestCase $this->clientMock->method('request')->willReturn(['url' => $uploadUrl]); $this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl)); - $this->clientMock->method('upload')->willReturn($uploadResponseJson); + $this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson); $result = $this->api->uploadAttachment(UploadType::Image, $filePath); @@ -480,7 +480,7 @@ final class ApiTest extends TestCase $this->clientMock->method('request')->willReturn(['url' => $uploadUrl]); $this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl)); - $this->clientMock->method('upload')->willReturn($uploadResponseJson); + $this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson); $result = $this->api->uploadAttachment(UploadType::File, $filePath); @@ -514,7 +514,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->with($uploadUrl, $this->isResource(), basename($filePath)) ->willReturn($uploadResponse); @@ -551,7 +551,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->with($uploadUrl, $this->isResource(), basename($filePath)) ->willReturn($uploadResponse); @@ -726,7 +726,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->with($uploadUrl, $this->isResource(), basename($filePath)) ->willReturn(json_encode($invalidUploadResponse)); @@ -769,7 +769,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->with($uploadUrl, $this->isResource(), basename($filePath)) ->willReturn(json_encode($uploadResponse)); @@ -1983,7 +1983,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->willReturn($invalidJsonResponse); try { @@ -2017,7 +2017,7 @@ final class ApiTest extends TestCase ->with($getUploadUrlResponse) ->willReturn($expectedEndpoint); - $this->clientMock->expects($this->never())->method('upload'); + $this->clientMock->expects($this->never())->method('multipartUpload'); try { $this->api->uploadAttachment(UploadType::Video, $filePath); @@ -2042,7 +2042,7 @@ final class ApiTest extends TestCase $this->clientMock ->expects($this->once()) - ->method('upload') + ->method('multipartUpload') ->willReturn($invalidUploadResponse); try { diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 6fb568c..4c2156f 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -313,7 +313,7 @@ final class ClientTest extends TestCase $this->responseMock->method('getStatusCode')->willReturn(200); $this->streamMock->method('__toString')->willReturn(json_encode($responsePayload)); - $result = $this->client->upload($uploadUrl, $fileContents, $fileName); + $result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName); $this->assertSame(json_encode($responsePayload), $result); } @@ -336,7 +336,7 @@ final class ClientTest extends TestCase $this->responseMock->method('getStatusCode')->willReturn(200); $this->streamMock->method('__toString')->willReturn(json_encode($responsePayload)); - $result = $this->client->upload($uploadUrl, $tmpFileHandle, $fileName); + $result = $this->client->multipartUpload($uploadUrl, $tmpFileHandle, $fileName); $this->assertSame(json_encode($responsePayload), $result); fclose($tmpFileHandle); @@ -358,7 +358,7 @@ final class ClientTest extends TestCase ->with($this->requestMock) ->willThrowException($psrException); - $this->client->upload('http://some.url', 'content', 'file.txt'); + $this->client->multipartUpload('http://some.url', 'content', 'file.txt'); } #[Test] @@ -406,7 +406,7 @@ final class ClientTest extends TestCase $this->responseMock->method('getStatusCode')->willReturn(200); $this->streamMock->method('__toString')->willReturn($rawResponse); - $result = $this->client->upload($uploadUrl, $fileContents, $fileName); + $result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName); $this->assertSame($rawResponse, $result); } }