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..627446f --- /dev/null +++ b/docs/source/file-uploads.rst @@ -0,0 +1,53 @@ +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 objec.t 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 `_. 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/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/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