Skip to content

Commit

Permalink
✨ Support new API version error format
Browse files Browse the repository at this point in the history
While FreshBooks API versions are not yet documented, the recent versions (2022-10-31 and forward) feature a slightly different response format when some /accounting endpoints fail.

Update the accounting handlers to handle both formats.
  • Loading branch information
amcintosh committed Jun 7, 2023
1 parent fa228b0 commit 5836dfd
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Remove warnings in PHP 8.2
- Handle new API version accounting errors

## 0.6.0

Expand Down
10 changes: 9 additions & 1 deletion src/Exception/FreshBooksException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ final class FreshBooksException extends \Exception
{
public ?string $rawResponse;
public ?int $errorCode;
public ?array $errorDetails;

public function __construct(
string $message,
int $statusCode,
Throwable $previous = null,
string $rawResponse = null,
int $errorCode = null
int $errorCode = null,
array $errorDetails = null
) {
parent::__construct($message, $statusCode, $previous);

$this->rawResponse = $rawResponse;
$this->errorCode = $errorCode;
$this->errorDetails = $errorDetails;
}

public function getRawResponse(): ?string
Expand All @@ -31,4 +34,9 @@ public function getErrorCode(): ?int
{
return $this->errorCode;
}

public function getErrorDetails(): ?array
{
return $this->errorDetails;
}
}
70 changes: 57 additions & 13 deletions src/Resource/AccountingResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,44 +78,88 @@ private function makeRequest(string $method, string $url, array $data = null): a
throw new FreshBooksException('Failed to parse response', $statusCode, $e, $contents);
}

if ($statusCode >= 400) {
$this->handleError($statusCode, $responseData, $contents);
}

if (is_null($responseData) || !array_key_exists('response', $responseData)) {
throw new FreshBooksException('Returned an unexpected response', $statusCode, null, $contents);
}

$responseData = $responseData['response'];

if ($statusCode >= 400) {
$this->createResponseError($statusCode, $responseData, $contents);
}

if (array_key_exists('result', $responseData)) {
return $responseData['result'];
}
return $responseData;
}

/**
* Parse the json response from the accounting endpoint and create a FreshBooksException from it.
* Parse the json response for old-style accounting endpoint errors and create a FreshBooksException from it.
*
* @param int $statusCode HTTP status code
* @param array $responseData The json-parsed response
* @param string $rawRespone The raw response body
* @return void
*/
private function createResponseError(int $statusCode, array $responseData, string $rawRespone): void
private function createOldResponseError(int $statusCode, array $responseData, string $rawRespone): void
{
if (!array_key_exists('errors', $responseData)) {
throw new FreshBooksException('Unknown error', $statusCode, null, $rawRespone);
}
$errors = $responseData['errors'];
$errors = $responseData['response']['errors'];
if (array_key_exists(0, $errors)) {
$message = $errors[0]['message'] ?? 'Unknown error2';
$message = $errors[0]['message'] ?? 'Unknown error';
$errorCode = $errors[0]['errno'] ?? null;
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode);
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode, $errors);
}

$message = $errors['message'] ?? 'Unknown error';
$errorCode = $errors['errno'] ?? null;
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode);
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode, $errors);
}

/**
* Parse the json response for new-style accounting endpoint errors and create a FreshBooksException from it.
*
* @param int $statusCode HTTP status code
* @param array $responseData The json-parsed response
* @param string $rawRespone The raw response body
* @return void
*/
private function createNewResponseError(int $statusCode, array $responseData, string $rawRespone): void
{
$message = $responseData['message'];
$details = [];

foreach ($responseData['details'] as $detail) {
if (in_array('type.googleapis.com/google.rpc.ErrorInfo', $detail)) {
$errorCode = intval($detail['reason']) ?? null;
if (array_key_exists('metadata', $detail)) {
$details[] = $detail['metadata'];
if (array_key_exists('message', $detail['metadata'])) {
$message = $detail['metadata']['message'];
}
}
}
}
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode, $details);
}

/**
* Create a FreshBooksException from the json response from the accounting endpoint.
*
* @param int $statusCode HTTP status code
* @param array $responseData The json-parsed response
* @param string $rawRespone The raw response body
* @return void
*/
private function handleError(int $statusCode, array $responseData, string $rawRespone): void
{
if (array_key_exists('response', $responseData) && array_key_exists('errors', $responseData['response'])) {
$this->createOldResponseError($statusCode, $responseData, $rawRespone);
} elseif (array_key_exists('message', $responseData) && array_key_exists('code', $responseData)) {
$this->createNewResponseError($statusCode, $responseData, $rawRespone);
} else {
throw new FreshBooksException('Unknown error', $statusCode, null, $rawRespone);
}
}

private function rejectMissing(string $name): void
Expand Down
30 changes: 27 additions & 3 deletions src/Resource/ProjectResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ private function getUrl(int $businessId, int $resourceId = null, bool $isList =
return "/projects/business/{$businessId}/{$this->singleResourcePath}";
}

/**
* Parse the json response for project endpoint errors and create a FreshBooksException from it.
*
* @param int $statusCode HTTP status code
* @param array $responseData The json-parsed response
* @param string $rawRespone The raw response body
* @return void
*/
private function createResponseError(int $statusCode, array $responseData, string $rawRespone): void
{
$message = $responseData['message'] ?? "Unknown error";
$errorCode = $responseData['code'] ?? null;
$errorDetails = null;

if (array_key_exists('error', $responseData) && is_array($responseData['error'])) {
$errorDetails = [];
foreach ($responseData['error'] as $errorKey => $errorDetail) {
$errorDetails[] = [$errorKey => $errorDetail];
$message = 'Error: ' . $errorKey . ' ' . $errorDetail;
}
} elseif (array_key_exists('error', $responseData)) {
$message = $responseData['error'];
}
throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode, $errorDetails);
}

/**
* Make a request against the accounting resource and return an array of the json response.
* Throws a FreshBooksException if the response is not a 200 or if the response cannot be parsed.
Expand Down Expand Up @@ -81,9 +107,7 @@ private function makeRequest(string $method, string $url, array $data = null): a
}

if ($statusCode >= 400) {
$errorCode = $responseData['code'] ?? null;
$message = $responseData['message'] ?? $responseData['error'] ?? "Unknown error";
throw new FreshBooksException($message, $statusCode, null, $contents, $errorCode);
$this->createResponseError($statusCode, $responseData, $contents);
}
if (
!array_key_exists($this->singleModel::RESPONSE_FIELD, $responseData) &&
Expand Down
82 changes: 73 additions & 9 deletions tests/Resource/AccountingResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,30 +88,94 @@ public function testGetWrongErrorContent(): void
$resource = new AccountingResource($mockHttpClient, 'users/clients', Client::class, ClientList::class);

$this->expectException(FreshBooksException::class);
$this->expectExceptionMessage('Returned an unexpected response');
$this->expectExceptionMessage('Unknown error');

$resource->get($this->accountId, $clientId);
}

public function testGetNoPermission(): void
public function testGetNotFoundOldError(): void
{
$clientId = 12345;
$mockHttpClient = $this->getMockHttpClient(
401,
404,
['response' => ['errors' => [[
'message' => 'The server could not verify that you are authorized to access the URL requested.',
'errno' => 1003
'errno' => 1012,
'field' => 'userid',
'message' => 'Client not found.',
'object' => 'client',
'value' => '12345'
]]]]
);

$resource = new AccountingResource($mockHttpClient, 'users/clients', Client::class, ClientList::class);

$this->expectException(FreshBooksException::class);
$this->expectExceptionMessage(
'The server could not verify that you are authorized to access the URL requested.'
try {
$resource->get($this->accountId, $clientId);
$this->fail('FreshBooksException was not thrown');
} catch (FreshBooksException $e) {
$this->assertSame('Client not found.', $e->getMessage());
$this->assertSame(404, $e->getCode());
$this->assertSame(1012, $e->getErrorCode());
$this->assertSame(
[
[
'errno' => 1012,
'field' => 'userid',
'message' => 'Client not found.',
'object' => 'client',
'value' => '12345'
]
],
$e->getErrorDetails()
);
}
}

public function testGetNotFoundNewError(): void
{
$clientId = 12345;
$mockHttpClient = $this->getMockHttpClient(
404,
[
'code' => 5,
'message' => 'Request failed with status_code: 404',
'details' => [
[
'@type' => 'type.googleapis.com/google.rpc.ErrorInfo',
'reason' => '1012',
'domain' => 'accounting.api.freshbooks.com',
'metadata' => [
'object' => 'client',
'message' => 'Client not found.',
'value' => '12345',
'field' => 'userid'
]
]
]
]
);

$resource->get($this->accountId, $clientId);
$resource = new AccountingResource($mockHttpClient, 'users/clients', Client::class, ClientList::class);

try {
$resource->get($this->accountId, $clientId);
$this->fail('FreshBooksException was not thrown');
} catch (FreshBooksException $e) {
$this->assertSame('Client not found.', $e->getMessage());
$this->assertSame(404, $e->getCode());
$this->assertSame(1012, $e->getErrorCode());
$this->assertSame(
[
[
'object' => 'client',
'message' => 'Client not found.',
'value' => '12345',
'field' => 'userid'
]
],
$e->getErrorDetails()
);
}
}

public function testList(): void
Expand Down
31 changes: 31 additions & 0 deletions tests/Resource/ProjectResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,37 @@ public function testCreateWithIncludes(): void
);
}

public function testCreateValidationErrors(): void
{
$mockHttpClient = $this->getMockHttpClient(
422,
[
'errno' => 2001,
'error' => [
'title' => 'field required',
'description' => 'field required'
]
]
);

$resource = new ProjectResource($mockHttpClient, 'projects', 'projects', Project::class, ProjectList::class);

try {
$resource->create($this->businessId, data: []);
$this->fail('FreshBooksException was not thrown');
} catch (FreshBooksException $e) {
$this->assertSame('Error: description field required', $e->getMessage());
$this->assertSame(422, $e->getCode());
$this->assertSame(
[
['title' => 'field required'],
['description' => 'field required']
],
$e->getErrorDetails()
);
}
}

public function testUpdateByModel(): void
{
$projectId = 12345;
Expand Down

0 comments on commit 5836dfd

Please sign in to comment.