mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-30 20:16:57 +00:00
Merge pull request #6 from BushlanovDev/resumable-upload
Resumable file upload
This commit is contained in:
+32
-2
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+92
-9
@@ -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 {
|
||||
@@ -2051,4 +2051,87 @@ final class ApiTest extends TestCase
|
||||
unlink($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function uploadFileUsesMultipartForSmallFiles(): void
|
||||
{
|
||||
$uploadUrl = 'https://upload.server/path';
|
||||
$fileName = 'small.txt';
|
||||
$fileContents = 'content';
|
||||
$fileHandle = fopen('php://memory', 'w+');
|
||||
fwrite($fileHandle, $fileContents);
|
||||
rewind($fileHandle);
|
||||
|
||||
$smallFileSize = strlen($fileContents);
|
||||
$expectedResponse = 'multipart-response';
|
||||
|
||||
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
|
||||
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $smallFileSize]);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('multipartUpload')
|
||||
->with($uploadUrl, $fileHandle, $fileName)
|
||||
->willReturn($expectedResponse);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->never())
|
||||
->method('resumableUpload');
|
||||
|
||||
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
|
||||
|
||||
$this->assertSame($expectedResponse, $result);
|
||||
fclose($fileHandle);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function uploadFileUsesResumableForLargeFiles(): void
|
||||
{
|
||||
$uploadUrl = 'https://upload.server/path';
|
||||
$fileName = 'large.zip';
|
||||
$fileHandle = fopen('php://memory', 'w+');
|
||||
|
||||
rewind($fileHandle);
|
||||
|
||||
$largeFileSize = 10 * 1024 * 1024;
|
||||
$expectedResponse = 'resumable-response';
|
||||
|
||||
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
|
||||
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $largeFileSize]);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('resumableUpload')
|
||||
->with($uploadUrl, $fileHandle, $fileName, $largeFileSize)
|
||||
->willReturn($expectedResponse);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->never())
|
||||
->method('multipartUpload');
|
||||
|
||||
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
|
||||
|
||||
$this->assertSame($expectedResponse, $result);
|
||||
fclose($fileHandle);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function uploadFileThrowsExceptionWhenFstatFails(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('File handle is not a valid resource.');
|
||||
|
||||
$fileHandle = fopen('php://memory', 'r');
|
||||
|
||||
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
|
||||
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(false);
|
||||
|
||||
$this->api->uploadFile('http://a.b', $fileHandle, 'file.txt');
|
||||
}
|
||||
}
|
||||
|
||||
+47
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -332,11 +332,11 @@ final class ClientTest extends TestCase
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturn($this->requestMock);
|
||||
$this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
|
||||
// $this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
|
||||
$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,49 @@ 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);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsExceptionForInvalidResource(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('fileResource must be a valid stream resource.');
|
||||
|
||||
$this->client->resumableUpload('http://a.b', 'not-a-resource', 'file.txt', 100);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsExceptionForZeroFileSize(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('File size must be greater than 0.');
|
||||
|
||||
$fileResource = fopen('php://memory', 'r');
|
||||
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 0);
|
||||
fclose($fileResource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsNetworkExceptionOnChunkUploadFailure(): void
|
||||
{
|
||||
$this->expectException(NetworkException::class);
|
||||
|
||||
$fileResource = fopen('php://memory', 'w+');
|
||||
fwrite($fileResource, 'some data');
|
||||
rewind($fileResource);
|
||||
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturnSelf();
|
||||
$this->requestMock->method('withHeader')->willReturnSelf();
|
||||
|
||||
$psrException = new class extends \Exception implements ClientExceptionInterface {};
|
||||
$this->httpClientMock
|
||||
->method('sendRequest')
|
||||
->willThrowException($psrException);
|
||||
|
||||
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 9);
|
||||
fclose($fileResource);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user