Skip to content

Commit 869c254

Browse files
authored
Merge pull request #6 from BushlanovDev/resumable-upload
Resumable file upload
2 parents ed715af + cb8ad3f commit 869c254

File tree

5 files changed

+259
-18
lines changed

5 files changed

+259
-18
lines changed

src/Api.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class Api
7575
private const string ACTION_ANSWERS = '/answers';
7676
private const string ACTION_VIDEO_DETAILS = '/videos/%s';
7777

78+
private const int RESUMABLE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10 MB
79+
7880
private readonly ClientApiInterface $client;
7981

8082
private readonly ModelFactory $modelFactory;
@@ -443,6 +445,33 @@ public function getUploadUrl(UploadType $type): UploadEndpoint
443445
);
444446
}
445447

448+
/**
449+
* @param string $uploadUrl The target URL for the upload.
450+
* @param resource $fileHandle A stream resource pointing to the file.
451+
* @param string $fileName The desired file name for the upload.
452+
*
453+
* @return string The body of the final response from the server.
454+
* @throws ClientApiException
455+
* @throws NetworkException
456+
* @throws SerializationException
457+
* @throws RuntimeException
458+
*/
459+
public function uploadFile(string $uploadUrl, mixed $fileHandle, string $fileName): string
460+
{
461+
$stat = fstat($fileHandle);
462+
if (!is_array($stat)) {
463+
throw new RuntimeException('File handle is not a valid resource.');
464+
}
465+
466+
rewind($fileHandle);
467+
468+
if ($stat['size'] < self::RESUMABLE_UPLOAD_THRESHOLD_BYTES) {
469+
return $this->client->multipartUpload($uploadUrl, $fileHandle, $fileName);
470+
}
471+
472+
return $this->client->resumableUpload($uploadUrl, $fileHandle, $fileName, $stat['size']);
473+
}
474+
446475
/**
447476
* A simplified method for uploading a file and getting the resulting attachment object.
448477
*
@@ -479,7 +508,8 @@ public function uploadAttachment(UploadType $type, string $filePath): AbstractAt
479508
"API did not return a pre-upload token for type '$type->value'."
480509
);
481510
}
482-
$this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath));
511+
512+
$this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
483513
fclose($fileHandle);
484514

485515
return match ($type) {
@@ -489,7 +519,7 @@ public function uploadAttachment(UploadType $type, string $filePath): AbstractAt
489519
}
490520

491521
// For images and files, the token is in the response *after* the upload.
492-
$responseBody = $this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath));
522+
$responseBody = $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
493523
fclose($fileHandle);
494524

495525
try {

src/Client.php

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Psr\Http\Message\StreamFactoryInterface;
2222
use Psr\Log\LoggerInterface;
2323
use Psr\Log\NullLogger;
24+
use RuntimeException;
2425

2526
/**
2627
* The low-level HTTP client responsible for communicating with the Max Bot API.
@@ -122,7 +123,7 @@ public function request(string $method, string $uri, array $queryParams = [], ar
122123
/**
123124
* @inheritDoc
124125
*/
125-
public function upload(string $uri, mixed $fileContents, string $fileName): string
126+
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string
126127
{
127128
$boundary = '--------------------------' . microtime(true);
128129
$bodyStream = $this->streamFactory->createStream();
@@ -155,6 +156,67 @@ public function upload(string $uri, mixed $fileContents, string $fileName): stri
155156
return (string)$response->getBody();
156157
}
157158

159+
/**
160+
* @inheritDoc
161+
*/
162+
public function resumableUpload(
163+
string $uploadUrl,
164+
mixed $fileResource,
165+
string $fileName,
166+
int $fileSize,
167+
int $chunkSize = 1048576,
168+
): string {
169+
if (!is_resource($fileResource) || get_resource_type($fileResource) !== 'stream') {
170+
throw new InvalidArgumentException('fileResource must be a valid stream resource.');
171+
}
172+
173+
// @phpstan-ignore-next-line
174+
if ($fileSize <= 0) {
175+
throw new InvalidArgumentException('File size must be greater than 0.');
176+
}
177+
178+
$startByte = 0;
179+
$finalResponseBody = '';
180+
181+
while (!feof($fileResource)) {
182+
$chunk = fread($fileResource, $chunkSize);
183+
if ($chunk === false) {
184+
throw new RuntimeException('Failed to read chunk from file stream.');
185+
}
186+
187+
$chunkLength = strlen($chunk);
188+
if ($chunkLength === 0) {
189+
break;
190+
}
191+
192+
$endByte = $startByte + $chunkLength - 1;
193+
194+
$chunkStream = $this->streamFactory->createStream($chunk);
195+
$request = $this->requestFactory->createRequest('POST', $uploadUrl)
196+
->withBody($chunkStream)
197+
->withHeader('Content-Type', 'application/octet-stream')
198+
->withHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"')
199+
->withHeader('Content-Range', "bytes {$startByte}-{$endByte}/{$fileSize}");
200+
201+
try {
202+
$response = $this->httpClient->sendRequest($request);
203+
} catch (ClientExceptionInterface $e) {
204+
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
205+
}
206+
207+
$this->handleErrorResponse($response);
208+
209+
// The final response might contain the retval
210+
$finalResponseBody = (string)$response->getBody();
211+
212+
$startByte += $chunkLength;
213+
}
214+
215+
// According to docs, for video/audio the token is sent separately,
216+
// and the upload response contains 'retval'. We return the body of the last response.
217+
return $finalResponseBody;
218+
}
219+
158220
/**
159221
* Checks the response for an error status code and throws a corresponding typed exception.
160222
*

src/ClientApiInterface.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
88
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
99
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
10+
use RuntimeException;
1011

1112
interface ClientApiInterface
1213
{
@@ -37,5 +38,28 @@ public function request(string $method, string $uri, array $queryParams = [], ar
3738
* @throws NetworkException
3839
* @throws SerializationException
3940
*/
40-
public function upload(string $uri, mixed $fileContents, string $fileName): string;
41+
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string;
42+
43+
/**
44+
* Uploads a file in chunks using the resumable upload method.
45+
* The caller is responsible for opening and closing the file resource.
46+
*
47+
* @param string $uploadUrl The target URL for the upload.
48+
* @param resource $fileResource A stream resource pointing to the file.
49+
* @param string $fileName The desired file name for the upload.
50+
* @param int<1, max> $fileSize The total size of the file in bytes.
51+
* @param int<1, max> $chunkSize The size of each chunk in bytes.
52+
*
53+
* @return string The body of the final response from the server.
54+
* @throws NetworkException
55+
* @throws ClientApiException
56+
* @throws RuntimeException
57+
*/
58+
public function resumableUpload(
59+
string $uploadUrl,
60+
$fileResource,
61+
string $fileName,
62+
int $fileSize,
63+
int $chunkSize = 1048576,
64+
): string;
4165
}

tests/ApiTest.php

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ public function uploadAttachmentForImage(): void
461461
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
462462
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
463463

464-
$this->clientMock->method('upload')->willReturn($uploadResponseJson);
464+
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
465465

466466
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
467467

@@ -480,7 +480,7 @@ public function uploadAttachmentForFile(): void
480480
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
481481
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
482482

483-
$this->clientMock->method('upload')->willReturn($uploadResponseJson);
483+
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
484484

485485
$result = $this->api->uploadAttachment(UploadType::File, $filePath);
486486

@@ -514,7 +514,7 @@ public function uploadAttachmentForAudio(): void
514514

515515
$this->clientMock
516516
->expects($this->once())
517-
->method('upload')
517+
->method('multipartUpload')
518518
->with($uploadUrl, $this->isResource(), basename($filePath))
519519
->willReturn($uploadResponse);
520520

@@ -551,7 +551,7 @@ public function uploadAttachmentForVideo(): void
551551

552552
$this->clientMock
553553
->expects($this->once())
554-
->method('upload')
554+
->method('multipartUpload')
555555
->with($uploadUrl, $this->isResource(), basename($filePath))
556556
->willReturn($uploadResponse);
557557

@@ -726,7 +726,7 @@ public function uploadAttachmentThrowsSerializationExceptionForInvalidUploadResp
726726

727727
$this->clientMock
728728
->expects($this->once())
729-
->method('upload')
729+
->method('multipartUpload')
730730
->with($uploadUrl, $this->isResource(), basename($filePath))
731731
->willReturn(json_encode($invalidUploadResponse));
732732

@@ -769,7 +769,7 @@ public function uploadAttachmentSuccessfullyUploadsFileAndReturnsAttachment(): v
769769

770770
$this->clientMock
771771
->expects($this->once())
772-
->method('upload')
772+
->method('multipartUpload')
773773
->with($uploadUrl, $this->isResource(), basename($filePath))
774774
->willReturn(json_encode($uploadResponse));
775775

@@ -1983,7 +1983,7 @@ public function uploadAttachmentThrowsSerializationExceptionOnInvalidUploadRespo
19831983

19841984
$this->clientMock
19851985
->expects($this->once())
1986-
->method('upload')
1986+
->method('multipartUpload')
19871987
->willReturn($invalidJsonResponse);
19881988

19891989
try {
@@ -2017,7 +2017,7 @@ public function uploadAttachmentForVideoThrowsExceptionOnMissingPreUploadToken()
20172017
->with($getUploadUrlResponse)
20182018
->willReturn($expectedEndpoint);
20192019

2020-
$this->clientMock->expects($this->never())->method('upload');
2020+
$this->clientMock->expects($this->never())->method('multipartUpload');
20212021

20222022
try {
20232023
$this->api->uploadAttachment(UploadType::Video, $filePath);
@@ -2042,7 +2042,7 @@ public function uploadAttachmentForFileThrowsExceptionOnMissingPostUploadToken()
20422042

20432043
$this->clientMock
20442044
->expects($this->once())
2045-
->method('upload')
2045+
->method('multipartUpload')
20462046
->willReturn($invalidUploadResponse);
20472047

20482048
try {
@@ -2051,4 +2051,87 @@ public function uploadAttachmentForFileThrowsExceptionOnMissingPostUploadToken()
20512051
unlink($filePath);
20522052
}
20532053
}
2054+
2055+
#[Test]
2056+
#[RunInSeparateProcess]
2057+
#[PreserveGlobalState(false)]
2058+
public function uploadFileUsesMultipartForSmallFiles(): void
2059+
{
2060+
$uploadUrl = 'https://upload.server/path';
2061+
$fileName = 'small.txt';
2062+
$fileContents = 'content';
2063+
$fileHandle = fopen('php://memory', 'w+');
2064+
fwrite($fileHandle, $fileContents);
2065+
rewind($fileHandle);
2066+
2067+
$smallFileSize = strlen($fileContents);
2068+
$expectedResponse = 'multipart-response';
2069+
2070+
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
2071+
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $smallFileSize]);
2072+
2073+
$this->clientMock
2074+
->expects($this->once())
2075+
->method('multipartUpload')
2076+
->with($uploadUrl, $fileHandle, $fileName)
2077+
->willReturn($expectedResponse);
2078+
2079+
$this->clientMock
2080+
->expects($this->never())
2081+
->method('resumableUpload');
2082+
2083+
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
2084+
2085+
$this->assertSame($expectedResponse, $result);
2086+
fclose($fileHandle);
2087+
}
2088+
2089+
#[Test]
2090+
#[RunInSeparateProcess]
2091+
#[PreserveGlobalState(false)]
2092+
public function uploadFileUsesResumableForLargeFiles(): void
2093+
{
2094+
$uploadUrl = 'https://upload.server/path';
2095+
$fileName = 'large.zip';
2096+
$fileHandle = fopen('php://memory', 'w+');
2097+
2098+
rewind($fileHandle);
2099+
2100+
$largeFileSize = 10 * 1024 * 1024;
2101+
$expectedResponse = 'resumable-response';
2102+
2103+
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
2104+
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $largeFileSize]);
2105+
2106+
$this->clientMock
2107+
->expects($this->once())
2108+
->method('resumableUpload')
2109+
->with($uploadUrl, $fileHandle, $fileName, $largeFileSize)
2110+
->willReturn($expectedResponse);
2111+
2112+
$this->clientMock
2113+
->expects($this->never())
2114+
->method('multipartUpload');
2115+
2116+
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
2117+
2118+
$this->assertSame($expectedResponse, $result);
2119+
fclose($fileHandle);
2120+
}
2121+
2122+
#[Test]
2123+
#[RunInSeparateProcess]
2124+
#[PreserveGlobalState(false)]
2125+
public function uploadFileThrowsExceptionWhenFstatFails(): void
2126+
{
2127+
$this->expectException(RuntimeException::class);
2128+
$this->expectExceptionMessage('File handle is not a valid resource.');
2129+
2130+
$fileHandle = fopen('php://memory', 'r');
2131+
2132+
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
2133+
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(false);
2134+
2135+
$this->api->uploadFile('http://a.b', $fileHandle, 'file.txt');
2136+
}
20542137
}

0 commit comments

Comments
 (0)