Added resumable upload

This commit is contained in:
Alex
2025-08-25 23:11:06 +03:00
parent ed715af3bc
commit 3127f86c54
5 changed files with 133 additions and 17 deletions
+32 -2
View File
@@ -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 {
+63 -1
View File
@@ -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.
*
+25 -1
View File
@@ -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;
}
+9 -9
View File
@@ -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 {
+4 -4
View File
@@ -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);
}
}