From 4661edffdfb734361039c2091bc02c4f0c07e745 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 19 Jul 2025 15:40:42 +0300 Subject: [PATCH] Added PhotoAttachment --- src/Api.php | 73 +++++++++++++++++ src/Client.php | 42 ++++++++++ src/ClientApiInterface.php | 14 ++++ src/Enums/UploadType.php | 13 +++ src/ModelFactory.php | 14 ++++ .../Payloads/PhotoAttachmentPayload.php | 32 ++++++++ .../Attachments/Payloads/PhotoToken.php | 22 +++++ .../Requests/PhotoAttachmentRequest.php | 59 +++++++++++++ src/Models/UploadEndpoint.php | 21 +++++ tests/ApiTest.php | 82 +++++++++++++++++++ tests/ClientTest.php | 62 ++++++++++---- 11 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 src/Enums/UploadType.php create mode 100644 src/Models/Attachments/Payloads/PhotoAttachmentPayload.php create mode 100644 src/Models/Attachments/Payloads/PhotoToken.php create mode 100644 src/Models/Attachments/Requests/PhotoAttachmentRequest.php create mode 100644 src/Models/UploadEndpoint.php diff --git a/src/Api.php b/src/Api.php index cfd8c29..81fe3bf 100644 --- a/src/Api.php +++ b/src/Api.php @@ -6,18 +6,23 @@ namespace BushlanovDev\MaxMessengerBot; use BushlanovDev\MaxMessengerBot\Enums\MessageFormat; use BushlanovDev\MaxMessengerBot\Enums\UpdateType; +use BushlanovDev\MaxMessengerBot\Enums\UploadType; use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException; use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; use BushlanovDev\MaxMessengerBot\Models\AbstractModel; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Message; use BushlanovDev\MaxMessengerBot\Models\MessageLink; use BushlanovDev\MaxMessengerBot\Models\Result; use BushlanovDev\MaxMessengerBot\Models\Subscription; +use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; use InvalidArgumentException; +use LogicException; use ReflectionException; +use RuntimeException; /** * The main entry point for interacting with the Max Bot API. @@ -37,6 +42,7 @@ class Api private const string ACTION_ME = '/me'; private const string ACTION_SUBSCRIPTIONS = '/subscriptions'; private const string ACTION_MESSAGES = '/messages'; + private const string ACTION_UPLOADS = '/uploads'; private readonly ClientApiInterface $client; @@ -204,4 +210,71 @@ class Api return $this->modelFactory->createMessage($response['message']); } + + /** + * Returns the URL for the subsequent file upload. + * + * @param UploadType $type Uploaded file type. + * + * @return UploadEndpoint Endpoint you should upload to your binaries. + * @throws ReflectionException + */ + public function getUploadUrl(UploadType $type): UploadEndpoint + { + return $this->modelFactory->createUploadEndpoint( + $this->client->request( + self::METHOD_POST, + self::ACTION_UPLOADS, + ['type' => $type->value], + ) + ); + } + + /** + * A simplified method for uploading a file and getting the resulting attachment object. + * + * @param UploadType $type Uploaded file type. + * @param string $filePath Path to the file on the local disk. + * + * @return AbstractAttachmentRequest + * @throws ReflectionException + * @throws SerializationException + * @throws InvalidArgumentException + * @throws RuntimeException + * @throws LogicException + * @throws NetworkException + * @throws ClientApiException + */ + public function uploadAttachment(UploadType $type, string $filePath): AbstractAttachmentRequest + { + if (!file_exists($filePath) || !is_readable($filePath)) { + throw new InvalidArgumentException("File not found or not readable: $filePath"); + } + + $fileHandle = fopen($filePath, 'r'); + if ($fileHandle === false) { + throw new RuntimeException("Could not open file for reading: $filePath"); + } + + $uploadEndpoint = $this->getUploadUrl($type); + + $uploadResult = $this->client->upload( + $uploadEndpoint->url, + $fileHandle, + basename($filePath), + ); + + fclose($fileHandle); + + if (!isset($uploadResult['token'])) { + throw new SerializationException('Could not find "token" in upload server response.'); + } + + return match ($type) { + UploadType::Image => PhotoAttachmentRequest::fromToken($uploadResult['token']), + default => throw new LogicException( + "Attachment creation for type '$type->value' is not yet implemented." + ), + }; + } } diff --git a/src/Client.php b/src/Client.php index fd3e8b1..20e90ae 100644 --- a/src/Client.php +++ b/src/Client.php @@ -100,6 +100,48 @@ final class Client implements ClientApiInterface } } + /** + * @inheritDoc + */ + public function upload(string $uri, mixed $fileContents, string $fileName): array + { + $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) + ->withBody($bodyStream); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new NetworkException($e->getMessage(), $e->getCode(), $e); + } + + $this->handleErrorResponse($response); + + $responseBody = (string)$response->getBody(); + + try { + return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SerializationException('Failed to decode upload server response JSON.', 0, $e); + } + } + /** * 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 50aec21..6313ae3 100644 --- a/src/ClientApiInterface.php +++ b/src/ClientApiInterface.php @@ -24,4 +24,18 @@ interface ClientApiInterface * @throws SerializationException for JSON encoding/decoding failures. */ public function request(string $method, string $uri, array $queryParams = [], array $body = []): array; + + /** + * Performs a file download at the specified URL. + * + * @param string $uri URL received from the download API. + * @param resource|string $fileContents File content (stream resource or string). + * @param string $fileName The name of the file that will be sent to the server. + * + * @return array + * @throws ClientApiException + * @throws NetworkException + * @throws SerializationException + */ + public function upload(string $uri, mixed $fileContents, string $fileName): array; } diff --git a/src/Enums/UploadType.php b/src/Enums/UploadType.php new file mode 100644 index 0000000..5edea7c --- /dev/null +++ b/src/Enums/UploadType.php @@ -0,0 +1,13 @@ + $data + * + * @return UploadEndpoint + * @throws ReflectionException + */ + public function createUploadEndpoint(array $data): UploadEndpoint + { + return UploadEndpoint::fromArray($data); + } } diff --git a/src/Models/Attachments/Payloads/PhotoAttachmentPayload.php b/src/Models/Attachments/Payloads/PhotoAttachmentPayload.php new file mode 100644 index 0000000..e7ddc4a --- /dev/null +++ b/src/Models/Attachments/Payloads/PhotoAttachmentPayload.php @@ -0,0 +1,32 @@ +url, $this->token, $this->photos])) !== 1) { + throw new InvalidArgumentException( + 'Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.' + ); + } + } +} diff --git a/src/Models/Attachments/Payloads/PhotoToken.php b/src/Models/Attachments/Payloads/PhotoToken.php new file mode 100644 index 0000000..dded503 --- /dev/null +++ b/src/Models/Attachments/Payloads/PhotoToken.php @@ -0,0 +1,22 @@ +assertSame($expectedMessageObject, $result); } + + #[Test] + public function uploadAttachmentSuccessfullyUploadsImageAndReturnsAttachment(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($filePath, 'fake-image-content'); + + $uploadType = UploadType::Image; + $uploadUrl = 'https://upload.server/gohere'; + $uploadToken = 'FINAL_TOKEN_123'; + + $getUploadUrlResponse = ['url' => $uploadUrl]; + $uploadResponse = ['token' => $uploadToken]; + $expectedEndpoint = new UploadEndpoint($uploadUrl); + $expectedAttachment = PhotoAttachmentRequest::fromToken($uploadToken); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('POST', '/uploads', ['type' => $uploadType->value]) + ->willReturn($getUploadUrlResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUploadEndpoint') + ->with($getUploadUrlResponse) + ->willReturn($expectedEndpoint); + + $this->clientMock + ->expects($this->once()) + ->method('upload') + ->with($uploadUrl, $this->isResource(), basename($filePath)) + ->willReturn($uploadResponse); + + $result = $this->api->uploadAttachment($uploadType, $filePath); + + $this->assertEquals($expectedAttachment, $result); + + unlink($filePath); + } + + #[Test] + public function uploadAttachmentForMultiplePhotosReturnsCorrectAttachment(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($filePath, 'content'); + + $getUploadUrlResponse = ['url' => 'http://upload.server']; + $expectedEndpoint = new UploadEndpoint('http://upload.server'); + + $uploadResponse = ['token' => 'token']; + + $expectedAttachment = PhotoAttachmentRequest::fromToken('token'); + + $this->clientMock->method('request')->willReturn($getUploadUrlResponse); + $this->modelFactoryMock->method('createUploadEndpoint')->willReturn($expectedEndpoint); + $this->clientMock->method('upload')->willReturn($uploadResponse); + + $result = $this->api->uploadAttachment(UploadType::Image, $filePath); + + $this->assertEquals($expectedAttachment, $result); + + unlink($filePath); + } + + #[Test] + public function uploadAttachmentThrowsExceptionForNonExistentFile(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/File not found or not readable/'); + $this->api->uploadAttachment(UploadType::Image, '/path/to/non/existent/file.jpg'); + } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index b0af670..cd74574 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -13,6 +13,7 @@ use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException; use BushlanovDev\MaxMessengerBot\Exceptions\RateLimitExceededException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException; +use GuzzleHttp\Psr7\HttpFactory; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -37,7 +38,7 @@ final class ClientTest extends TestCase private MockObject&ClientInterface $httpClientMock; private MockObject&RequestFactoryInterface $requestFactoryMock; - private MockObject&StreamFactoryInterface $streamFactoryMock; + private StreamFactoryInterface $streamFactory; private MockObject&RequestInterface $requestMock; private MockObject&ResponseInterface $responseMock; private MockObject&StreamInterface $streamMock; @@ -56,7 +57,7 @@ final class ClientTest extends TestCase // Create mocks for all PSR interfaces $this->httpClientMock = $this->createMock(ClientInterface::class); $this->requestFactoryMock = $this->createMock(RequestFactoryInterface::class); - $this->streamFactoryMock = $this->createMock(StreamFactoryInterface::class); + $this->streamFactory = new HttpFactory(); $this->requestMock = $this->createMock(RequestInterface::class); $this->responseMock = $this->createMock(ResponseInterface::class); $this->streamMock = $this->createMock(StreamInterface::class); @@ -65,15 +66,13 @@ final class ClientTest extends TestCase $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); $this->responseMock->method('getBody')->willReturn($this->streamMock); $this->httpClientMock->method('sendRequest')->willReturn($this->responseMock); - $this->requestMock->method('withBody')->willReturn($this->requestMock); $this->requestMock->method('withHeader')->willReturn($this->requestMock); - // Instantiate the System Under Test (SUT) $this->client = new Client( self::FAKE_TOKEN, $this->httpClientMock, $this->requestFactoryMock, - $this->streamFactoryMock, + $this->streamFactory, self::API_BASE_URL, self::API_VERSION, ); @@ -85,7 +84,7 @@ final class ClientTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Access token cannot be empty.'); - new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactoryMock, '', ''); + new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactory, '', ''); } #[Test] @@ -134,7 +133,6 @@ final class ClientTest extends TestCase ], ]; $responsePayload = ['success' => true]; - $expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([ 'access_token' => self::FAKE_TOKEN, 'v' => self::API_VERSION @@ -146,16 +144,13 @@ final class ClientTest extends TestCase ->with('POST', $expectedUrl) ->willReturn($this->requestMock); - $this->streamFactoryMock - ->expects($this->once()) - ->method('createStream') - ->with(json_encode($requestBody)) - ->willReturn($this->streamMock); - $this->requestMock ->expects($this->once()) ->method('withBody') - ->with($this->streamMock) + ->with($this->callback(function (StreamInterface $stream) use ($requestBody) { + $this->assertSame(json_encode($requestBody), $stream->getContents()); + return true; + })) ->willReturn($this->requestMock); $this->requestMock @@ -273,4 +268,43 @@ final class ClientTest extends TestCase throw $e; // Re-throw for PHPUnit to catch the expected exception type } } + + #[Test] + public function uploadMethodSendsCorrectMultipartRequest(): void + { + $uploadUrl = 'https://upload.server/path'; + $fileContents = 'fake-image-binary-data'; + $fileName = 'test.jpg'; + $responsePayload = ['token' => 'upload_successful_token']; + + $this->requestMock + ->expects($this->once()) + ->method('withBody') + ->with($this->callback(function (StreamInterface $stream) use ($fileContents, $fileName) { + $stream->rewind(); + $body = $stream->getContents(); + $this->assertStringContainsString('Content-Disposition: form-data; name="data"; filename="' . $fileName . '"', $body); + $this->assertStringContainsString($fileContents, $body); + return true; + })) + ->willReturn($this->requestMock); + + $this->requestMock + ->expects($this->once()) + ->method('withHeader') + ->with($this->stringStartsWith('Content-Type'), $this->stringStartsWith('multipart/form-data')) + ->willReturn($this->requestMock); + + $this->requestFactoryMock + ->expects($this->once()) + ->method('createRequest') + ->with('POST', $uploadUrl) + ->willReturn($this->requestMock); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->streamMock->method('__toString')->willReturn(json_encode($responsePayload)); + + $result = $this->client->upload($uploadUrl, $fileContents, $fileName); + $this->assertSame($responsePayload, $result); + } }