diff --git a/CHANGELOG.md b/CHANGELOG.md index d24839a..5bcff17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Support for PHP 8.3 +- Handle file uploads and invoice, expense attachments - Handle new API version webhook event errors ## 0.7.0 diff --git a/README.md b/README.md index 23178bc..f9306e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Packagist Version](https://badgen.net/packagist/v/amcintosh/freshbooks)](https://packagist.org/packages/amcintosh/freshbooks) ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/amcintosh/freshbooks) -[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/amcintosh/freshbooks-php-sdk/Run%20Tests)](https://github.com/amcintosh/freshbooks-php-sdk/actions?query=workflow%3A%22Run+Tests%22) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/amcintosh/freshbooks-php-sdk/run-tests.yml?branch=main)](https://github.com/amcintosh/freshbooks-php-sdk/actions?query=workflow%3A%22Run+Tests%22) A FreshBooks PHP SDK to allow you to more easily utilize the [FreshBooks API](https://www.freshbooks.com/api). This library is not directly maintained by FreshBooks and [community contributions](CONTRIBUTING.md) are welcome. diff --git a/composer.json b/composer.json index 3ea46de..ed7805c 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "php": ">=8.0 <8.4", "php-http/client-common": "^2.5", "php-http/discovery": "^1.14", + "php-http/multipart-stream-builder": "^1.3", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "spatie/data-transfer-object": "^3.8", diff --git a/docs/source/api-calls/errors.rst b/docs/source/api-calls/errors.rst index a0ca9e0..38aa1d5 100644 --- a/docs/source/api-calls/errors.rst +++ b/docs/source/api-calls/errors.rst @@ -17,8 +17,8 @@ Example: echo $e->getCode(); // 404 echo $e->getErrorCode(); // 1012 echo $e->getRawResponse(); // '{"response": {"errors": [{"errno": 1012, - // "field": "userid", "message": "Client not found.", - // "object": "client", "value": "134"}]}}' + // "field": "userid", "message": "Client not found.", + // "object": "client", "value": "134"}]}}' } Not all resources have full CRUD methods available. For example expense categories have ``list`` and ``get`` diff --git a/docs/source/file-uploads.rst b/docs/source/file-uploads.rst new file mode 100644 index 0000000..bc16b6d --- /dev/null +++ b/docs/source/file-uploads.rst @@ -0,0 +1,73 @@ +File Uploads +============ + +Some FreshBooks resource can include images and attachments. For example, invoices can have a company +logo or banner image as part of the invoice presentation object as well as images or pdfs attachments. +Expenses can also include copies or photos of receipts as attachments. + +All images and attachments first need to be uploaded to FreshBooks via the ``images`` or ``attachments`` +endpoints. + +These will then return a path to your file with a JWT. This path will can then be passed as part of the +data in a subsequent call. + +See FreshBooks' `invoice attachment `_ +and `expense attachment `_ +documentation for more information. + +Invoice Images and Attachments +------------------------------ + +See `FreshBooks' API Documentation `_. + +The ``upload()`` function takes a `PHP resource `_. +Logo's and banners are added to the invoice presentation object. To include an uploaded attachment on +an invoice, the invoice request must include an attachments object. + +.. code-block:: php + $logo = $freshBooksClient->images()->upload($accountId, fopen('./sample_logo.png', 'r')); + $attachment = $freshBooksClient->attachments()->upload($accountId, fopen('./sample_attachment.pdf', 'r')); + + $presentation = [ + 'image_logo_src' => "/uploads/images/{$logo->jwt}", + 'theme_primary_color' => '#1fab13', + 'theme_layout' => 'simple' + ]; + + $invoiceData = [ + 'customerid' => $clientId, + 'attachments' => [ + [ + 'jwt' => $attachment->jwt, + 'media_type' => $attachment->mediaType + ] + ], + 'presentation' => presentation + ]; + + $invoice = $freshBooksClient->invoices()->create($accountId, $invoiceData); + +Expense Receipts +---------------- + +See `FreshBooks' API Documentation `_. + +Expenses have have images or PDFs of the associated receipt attached. The expense request must include +an attachments object. + +.. code-block:: php + $attachment = $freshBooksClient->attachments()->upload($accountId, fopen('./sample_receipt.pdf', 'r')); + + $expense->amount = new Money("6.49", "CAD"); + $expense->date = new DateTime(); + $expense->staffId = 1; + $expense->categoryId = 3436009; + + $expenseAttachment = new ExpenseAttachment(); + $expenseAttachment->jwt = $attachment->jwt; + $expenseAttachment->mediaType = $attachment->mediaType; + + $expense->attachment = $expenseAttachment; + + $includes = (new IncludesBuilder())->include('attachment'); + $expense = $freshBooksClient->expenses()->create($accountId, model: $expense, includes: $includes); diff --git a/docs/source/index.rst b/docs/source/index.rst index 4678095..429920d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,7 @@ configuration authorization api-calls/index + file-uploads webhooks examples diff --git a/src/FreshBooksClient.php b/src/FreshBooksClient.php index bf1ebbe..5301af0 100644 --- a/src/FreshBooksClient.php +++ b/src/FreshBooksClient.php @@ -43,6 +43,7 @@ use amcintosh\FreshBooks\Resource\EventsResource; use amcintosh\FreshBooks\Resource\PaymentResource; use amcintosh\FreshBooks\Resource\ProjectResource; +use amcintosh\FreshBooks\Resource\UploadResource; /** * SDK Client. @@ -349,4 +350,24 @@ public function invoicePaymentOptions(): PaymentResource staticPathParams: 'entity_type=invoice', ); } + + /** + * FreshBooks attachment upload resource with call to upload, get + * + * @return UploadResource + */ + public function attachments(): UploadResource + { + return new UploadResource($this->httpClient, 'attachments', 'attachment'); + } + + /** + * FreshBooks image upload resource with call to upload, get + * + * @return UploadResource + */ + public function images(): UploadResource + { + return new UploadResource($this->httpClient, 'images', 'image'); + } } diff --git a/src/Model/ExpenseAttachment.php b/src/Model/ExpenseAttachment.php index ac47ba8..1e1f762 100644 --- a/src/Model/ExpenseAttachment.php +++ b/src/Model/ExpenseAttachment.php @@ -4,16 +4,11 @@ namespace amcintosh\FreshBooks\Model; -use DateTime; -use DateTimeImmutable; -use Spatie\DataTransferObject\Attributes\CastWith; use Spatie\DataTransferObject\Attributes\MapFrom; use Spatie\DataTransferObject\Attributes\MapTo; use Spatie\DataTransferObject\Caster; use Spatie\DataTransferObject\DataTransferObject; use amcintosh\FreshBooks\Model\DataModel; -use amcintosh\FreshBooks\Model\Caster\AccountingDateTimeImmutableCaster; -use amcintosh\FreshBooks\Model\Caster\MoneyCaster; /** * Attached receipt image details for an expense. @@ -22,7 +17,7 @@ * present with the use of a corresponding "includes" filter. * * @package amcintosh\FreshBooks\Model - * @link https://www.freshbooks.com/api/expenses + * @link https://www.freshbooks.com/api/expense-attachments */ class ExpenseAttachment extends DataTransferObject implements DataModel { diff --git a/src/Model/FileUpload.php b/src/Model/FileUpload.php new file mode 100644 index 0000000..ca1a145 --- /dev/null +++ b/src/Model/FileUpload.php @@ -0,0 +1,49 @@ +fileName = $fileName; + $this->mediaType = $mediaType; + $this->responseBody = $responseBody; + } +} diff --git a/src/Model/InvoiceAttachment.php b/src/Model/InvoiceAttachment.php new file mode 100644 index 0000000..b56b335 --- /dev/null +++ b/src/Model/InvoiceAttachment.php @@ -0,0 +1,74 @@ +except('id') + ->except('attachmentId') + ->toArray(); + foreach ($data as $key => $value) { + if (is_null($value)) { + unset($data[$key]); + } + } + return $data; + } +} diff --git a/src/Resource/UploadResource.php b/src/Resource/UploadResource.php new file mode 100644 index 0000000..35bf898 --- /dev/null +++ b/src/Resource/UploadResource.php @@ -0,0 +1,159 @@ +httpClient = $httpClient; + $this->uploadPath = $uploadPath; + $this->resourceName = $resourceName; + } + + /** + * The the url to the upload resource. + * + * @param string $accountId + * @param int $resourceId + * @return string + */ + protected function getUrl(string $accountId = null, string $jwt = null): string + { + if (!is_null($accountId)) { + return "/uploads/account/{$accountId}/{$this->uploadPath}"; + } + return "/uploads/{$this->uploadPath}/{$jwt}"; + } + + /** + * Create a FreshBooksException from the json response from the uploads endpoint. + * + * @param int $statusCode HTTP status code + * @param string $contents The response contents + * @return void + */ + protected function handleError(int $statusCode, string $contents): void + { + try { + $responseData = json_decode($contents, true); + } catch (JSONDecodeError $e) { + throw new FreshBooksException('Failed to parse response', $statusCode, $e, $contents); + } + $message = $responseData['error'] ?? "Unknown error"; + throw new FreshBooksException($message, $statusCode, null, $contents, null, null); + } + + /** + * Make a request against the uploads resource. Returns an object containing a + * Psr\Http\Message\StreamInterface for flexibility. + * Throws a FreshBooksException if the response is not a 200. + * + * @param string $url + * @return FileUpload + */ + private function makeGetFileRequest(string $url): FileUpload + { + $response = $this->httpClient->send(self::GET, $url); + $responseBody = $response->getBody(); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + $this->handleError($statusCode, $responseBody->getContents()); + } + $fileName = $response->getHeader('X-filename'); + if (!is_null($fileName) && count($fileName) > 0) { + $fileName = $fileName[0]; + } + $mediaType = $response->getHeader('Content-Type'); + if (!is_null($mediaType) && count($mediaType) > 0) { + $mediaType = $mediaType[0]; + } + + return new FileUpload($fileName, $mediaType, $responseBody); + } + + /** + * Make creates a POST request to upload a file to FreshBooks. + * Throws a FreshBooksException if the response is not a 200. + * + * @param string $url + * @return FileUpload + */ + private function makeUploadRequest(string $url, $file): FileUpload + { + // Thank you https://dev.to/timoschinkel/sending-multipart-data-with-psr-18-2lb5 + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + $builder = new MultipartStreamBuilder($streamFactory); + $builder->addResource('content', $file); + + $requestFactory = Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory + ->createRequest('POST', $url) + ->withHeader('Content-Type', 'multipart/form-data; boundary="' . $builder->getBoundary() . '"') + ->withBody($builder->build()); + + $response = $this->httpClient->sendRequest($request); + + $statusCode = $response->getStatusCode(); + $responseBody = $response->getBody(); + if ($statusCode >= 400) { + $this->handleError($statusCode, $responseBody->getContents()); + } + try { + $contents = $responseBody->getContents(); + $responseData = json_decode($contents, true); + } catch (JSONDecodeError $e) { + throw new FreshBooksException('Failed to parse response', $statusCode, $e, $contents); + } + if (is_null($responseData) || !array_key_exists($this->resourceName, $responseData)) { + throw new FreshBooksException('Returned an unexpected response', $statusCode, null, $contents); + } + $link = $responseData['link'] ?? null; + $responseData = $responseData[$this->resourceName]; + $fileData = new FileUpload($responseData['filename'], $responseData['media_type'], null); + $fileData->link = $link; + $fileData->jwt = $responseData['jwt']; + return $fileData; + } + + /** + * Get an uploaded file. + * + * @param string $jwt JWT provided by FreshBooks when the file was uploaded. + * @return FileUpload Object containing the file name, content type, and stream of data. + */ + public function get(string $jwt): FileUpload + { + return $this->makeGetFileRequest($this->getUrl(jwt: $jwt)); + } + + /** + * Upload a file to FreshBooks. + * + * @param string $accountId The alpha-numeric account id + * @param resource File resource to upload + * @return FileUpload Object containing the JWT, file name, content type. + */ + public function upload(string $accountId, $file): FileUpload + { + $url = $this->getUrl($accountId); + return $this->makeUploadRequest($url, $file); + } +} diff --git a/tests/Resource/BaseResourceTest.php b/tests/Resource/BaseResourceTest.php index 9ba30b3..d093641 100644 --- a/tests/Resource/BaseResourceTest.php +++ b/tests/Resource/BaseResourceTest.php @@ -23,4 +23,28 @@ public function getMockHttpClient(int $status = 200, array $content = null): Moc $mockHttpClient->addResponse($response); return $mockHttpClient; } + + public function getMockFileHttpClient( + int $status = 200, + $file = null, + $fileName = null, + $contentType = null + ): MockHttpClient { + $mockHttpClient = new MockHttpClient( + Psr17FactoryDiscovery::findRequestFactory(), + Psr17FactoryDiscovery::findStreamFactory() + ); + $fileHandle = fopen($file, 'r'); + $headers = [ + ['X-filename', [$fileName]], + ['Content-Type', [$contentType]] + ]; + + $response = $this->createMock('Psr\Http\Message\ResponseInterface'); + $response->method('getStatusCode')->will($this->returnValue($status)); + $response->method('getBody')->will($this->returnValue(Psr7\Utils::streamFor($fileHandle))); + $response->method('getHeader')->will($this->returnValueMap($headers)); + $mockHttpClient->addResponse($response); + return $mockHttpClient; + } } diff --git a/tests/Resource/UploadResourceTest.php b/tests/Resource/UploadResourceTest.php new file mode 100644 index 0000000..f99b2ff --- /dev/null +++ b/tests/Resource/UploadResourceTest.php @@ -0,0 +1,144 @@ +accountId = 'ACM123'; + } + + + public function testGet(): void + { + $fileName = 'sample_logo.png'; + $contentType = 'image/png'; + $mockHttpClient = $this->getMockFileHttpClient( + 200, + 'tests/Util/sample_logo.png', + $fileName, + $contentType + ); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + $image = $resource->get($this->accountId, 'SOME_JWT'); + + $this->assertSame($fileName, $image->fileName); + $this->assertSame($contentType, $image->mediaType); + $request = $mockHttpClient->getLastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/uploads/images/ACM123', $request->getRequestTarget()); + } + + public function testGetWrongErrorContent(): void + { + $mockHttpClient = $this->getMockHttpClient(400, ['foo' => 'bar']); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $this->expectException(FreshBooksException::class); + $this->expectExceptionMessage('Unknown error'); + + $resource->get('ACM123'); + } + + public function testGetNotFound(): void + { + $mockHttpClient = $this->getMockHttpClient( + 404, + ['error' => 'File not found'] + ); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $this->expectException(FreshBooksException::class); + $this->expectExceptionMessage('File not found'); + + $resource->get('ACM123'); + } + + public function testGetUnknownError(): void + { + $mockHttpClient = $this->getMockHttpClient(500); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $this->expectException(FreshBooksException::class); + $this->expectExceptionMessage('Unknown error'); + + $resource->get('ACM123'); + } + + + public function testUpload(): void + { + $jwt = 'some_jwt'; + $fileName = 'upload-x123'; + $contentType = 'image/png'; + $mockHttpClient = $this->getMockHttpClient( + 200, + [ + 'image' => [ + 'filename' => $fileName, + 'public_id' => $jwt, + 'jwt' => $jwt, + 'media_type' => $contentType, + 'uuid' => 'some_uuid' + ], + 'link' => "https://my.freshbooks.com/service/uploads/images/{$jwt}" + ] + ); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $imageData = $resource->upload($this->accountId, fopen('tests/Util/sample_logo.png', 'r')); + + $this->assertSame($fileName, $imageData->fileName); + $this->assertSame($contentType, $imageData->mediaType); + $this->assertSame($jwt, $imageData->jwt); + $this->assertSame('https://my.freshbooks.com/service/uploads/images/some_jwt', $imageData->link); + + $request = $mockHttpClient->getLastRequest(); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/uploads/account/ACM123/images', $request->getRequestTarget()); + } + + public function testUploadBadRequest(): void + { + $mockHttpClient = $this->getMockHttpClient( + 400, + ['error' => 'Content required'] + ); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $this->expectException(FreshBooksException::class); + $this->expectExceptionMessage('Content required'); + + $imageData = $resource->upload($this->accountId, fopen('tests/Util/sample_logo.png', 'r')); + } + + public function testUploadUnknownError(): void + { + $mockHttpClient = $this->getMockHttpClient(500); + + $resource = new UploadResource($mockHttpClient, 'images', 'image'); + + $this->expectException(FreshBooksException::class); + $this->expectExceptionMessage('Unknown error'); + + $imageData = $resource->upload($this->accountId, fopen('tests/Util/sample_logo.png', 'r')); + } +} diff --git a/tests/Util/sample_logo.png b/tests/Util/sample_logo.png new file mode 100644 index 0000000..e9eede5 Binary files /dev/null and b/tests/Util/sample_logo.png differ