Skip to content

Commit

Permalink
✨ Add file upload resources
Browse files Browse the repository at this point in the history
Allows for uploads of images and attachments.
Includes documentation on invoice usage.

Expense usage documentation to follow.

Issue#67
  • Loading branch information
amcintosh committed Apr 17, 2024
1 parent 2ae347b commit 761dac3
Show file tree
Hide file tree
Showing 10 changed files with 454 additions and 2 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions docs/source/api-calls/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
53 changes: 53 additions & 0 deletions docs/source/file-uploads.rst
Original file line number Diff line number Diff line change
@@ -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 <https://www.freshbooks.com/api/invoice_presentation_attachments>`_
and `expense attachment <https://www.freshbooks.com/api/https://www.freshbooks.com/api/expense-attachments>`_
documentation for more information.

Invoice Images and Attachments
------------------------------

See `FreshBooks' API Documentation <https://www.freshbooks.com/api/invoice_presentation_attachments>`_.

The ``upload()`` function takes a `PHP resource <https://www.php.net/manual/en/language.types.resource.php>`_.
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 <https://www.freshbooks.com/api/expense-attachments>`_.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
configuration
authorization
api-calls/index
file-uploads
webhooks
examples

Expand Down
21 changes: 21 additions & 0 deletions src/FreshBooksClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}
}
49 changes: 49 additions & 0 deletions src/Model/FileUpload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace amcintosh\FreshBooks\Model;

use Psr\Http\Message\StreamInterface;

/**
* A file that has been uploaded to FreshBooks.
*
* @package amcintosh\FreshBooks\Model
*/
class FileUpload
{
/**
* @var string The JWT used to fetch the file from FreshBooks.
*/
public ?string $jwt;

/**
* @var string The name of the file uploaded to FreshBooks.
*
* This is returned from the API in the `X-filename` header.
*/
public ?string $fileName;

/**
* @var string The media type (eg. `image/png`) of the file uploaded to FreshBooks.
*/
public ?string $mediaType;

/**
* @var string The PSR StreamInterface steam of data from the request body.
*/
public ?StreamInterface $responseBody;

/**
* @var string A fully qualified path the the file from FreshBooks.
*/
public ?string $link;

public function __construct(?string $fileName, ?string $mediaType, ?StreamInterface $responseBody)
{
$this->fileName = $fileName;
$this->mediaType = $mediaType;
$this->responseBody = $responseBody;
}
}
159 changes: 159 additions & 0 deletions src/Resource/UploadResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace amcintosh\FreshBooks\Resource;

use Http\Client\HttpClient;
use Http\Discovery\HttpClientDiscovery;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Message\MultipartStream\MultipartStreamBuilder;
use amcintosh\FreshBooks\Exception\FreshBooksException;
use amcintosh\FreshBooks\Model\FileUpload;

class UploadResource extends BaseResource
{
protected HttpClient $httpClient;
protected string $uploadPath;
protected string $resourceName;

public function __construct(
HttpClient $httpClient,
string $uploadPath,
string $resourceName,
) {
$this->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);
}
}
24 changes: 24 additions & 0 deletions tests/Resource/BaseResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading

0 comments on commit 761dac3

Please sign in to comment.