From 0eb7ec88616d07fb741e8cabfe16e0720024f382 Mon Sep 17 00:00:00 2001 From: Andrew McIntosh Date: Mon, 2 Oct 2023 18:56:31 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Webhook=20Callbacks=20endpoint=20CR?= =?UTF-8?q?UD=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding callbacks resource for registering and managing webhooks. This provides the basic CRUD operations. Verification and resending methods to follow. See Issue#44 --- CHANGELOG.md | 1 + src/FreshBooksClient.php | 14 +++ src/Model/Callback.php | 73 ++++++++++++++++ src/Model/CallbackList.php | 24 ++++++ src/Resource/AccountingResource.php | 17 ++-- src/Resource/EventsResource.php | 67 ++++++++++++++ tests/Model/CallbackTest.php | 42 +++++++++ tests/Resource/EventsResourceTest.php | 120 ++++++++++++++++++++++++++ 8 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/Model/Callback.php create mode 100644 src/Model/CallbackList.php create mode 100644 src/Resource/EventsResource.php create mode 100644 tests/Model/CallbackTest.php create mode 100644 tests/Resource/EventsResourceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efb4c2..25df644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added Webhook Callback resource - Remove warnings in PHP 8.2 - Handle new API version accounting errors diff --git a/src/FreshBooksClient.php b/src/FreshBooksClient.php index 1f1dbc3..eb21edf 100644 --- a/src/FreshBooksClient.php +++ b/src/FreshBooksClient.php @@ -16,6 +16,8 @@ use Psr\Http\Message\StreamFactoryInterface; use amcintosh\FreshBooks\Exception\FreshBooksClientConfigException; use amcintosh\FreshBooks\Model\AuthorizationToken; +use amcintosh\FreshBooks\Model\Callback; +use amcintosh\FreshBooks\Model\CallbackList; use amcintosh\FreshBooks\Model\Client; use amcintosh\FreshBooks\Model\ClientList; use amcintosh\FreshBooks\Model\Expense; @@ -37,6 +39,7 @@ use amcintosh\FreshBooks\Model\TaxList; use amcintosh\FreshBooks\Resource\AccountingResource; use amcintosh\FreshBooks\Resource\AuthResource; +use amcintosh\FreshBooks\Resource\EventsResource; use amcintosh\FreshBooks\Resource\ProjectResource; class FreshBooksClient @@ -302,6 +305,17 @@ public function taxes(): AccountingResource return new AccountingResource($this->httpClient, 'taxes/taxes', Tax::class, TaxList::class); } + /** + * FreshBooks callbacks (webhook callbacks) resource with calls to + * get, list, create, update, delete, resend_verification, verify + * + * @return EventsResource + */ + public function callbacks(): EventsResource + { + return new EventsResource($this->httpClient, 'events/callbacks', Callback::class, CallbackList::class); + } + /** * FreshBooks projects resource with calls to get, list, create, update, delete. * diff --git a/src/Model/Callback.php b/src/Model/Callback.php new file mode 100644 index 0000000..daa664f --- /dev/null +++ b/src/Model/Callback.php @@ -0,0 +1,73 @@ +except('callbackId') + ->except('updatedAt') + ->except('verified') + ->toArray(); + foreach ($data as $key => $value) { + if (is_null($value)) { + unset($data[$key]); + } + } + return $data; + } +} diff --git a/src/Model/CallbackList.php b/src/Model/CallbackList.php new file mode 100644 index 0000000..48c891e --- /dev/null +++ b/src/Model/CallbackList.php @@ -0,0 +1,24 @@ +accountingPath}/{$resourceId}"; @@ -127,6 +127,7 @@ private function createOldResponseError(int $statusCode, array $responseData, st private function createNewResponseError(int $statusCode, array $responseData, string $rawRespone): void { $message = $responseData['message']; + $errorCode = null; $details = []; foreach ($responseData['details'] as $detail) { @@ -151,7 +152,7 @@ private function createNewResponseError(int $statusCode, array $responseData, st * @param string $rawRespone The raw response body * @return void */ - private function handleError(int $statusCode, array $responseData, string $rawRespone): void + protected 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); diff --git a/src/Resource/EventsResource.php b/src/Resource/EventsResource.php new file mode 100644 index 0000000..0597773 --- /dev/null +++ b/src/Resource/EventsResource.php @@ -0,0 +1,67 @@ +accountingPath}/{$resourceId}"; + } + return "/events/account/{$accountId}/{$this->accountingPath}"; + } + + /** + * Create a FreshBooksException from the json response from the events endpoint. + * + * @param int $statusCode HTTP status code + * @param array $responseData The json-parsed response + * @param string $rawRespone The raw response body + * @return void + */ + protected function handleError(int $statusCode, array $responseData, string $rawRespone): void + { + if (!array_key_exists('message', $responseData) || !array_key_exists('code', $responseData)) { + throw new FreshBooksException('Unknown error', $statusCode, null, $rawRespone); + } + + $message = $responseData['message']; + $errorCode = null; + $details = []; + + foreach ($responseData['details'] as $detail) { + if ( + in_array('type.googleapis.com/google.rpc.BadRequest', $detail) + && array_key_exists('fieldViolations', $detail) + ) { + $details = $detail['fieldViolations']; + } + } + var_dump($details); + throw new FreshBooksException($message, $statusCode, null, $rawRespone, $errorCode, $details); + } +} diff --git a/tests/Model/CallbackTest.php b/tests/Model/CallbackTest.php new file mode 100644 index 0000000..82fbd64 --- /dev/null +++ b/tests/Model/CallbackTest.php @@ -0,0 +1,42 @@ +sampleCallbackData, true); + + $callback = new Callback($callbackData[Callback::RESPONSE_FIELD]); + + $this->assertSame(123, $callback->callbackId); + $this->assertSame('invoice.create', $callback->event); + $this->assertEquals(new DateTime('2017-08-23T11:45:09Z'), $callback->updatedAt); + $this->assertSame('http://freshbooks.com/hook/123', $callback->uri); + $this->assertTrue($callback->verified); + } + + public function testCallbackGetContent(): void + { + $callbackData = json_decode($this->sampleCallbackData, true); + $callback = new Callback($callbackData['callback']); + $this->assertSame([ + 'event' => 'invoice.create', + 'uri' => 'http://freshbooks.com/hook/123' + ], $callback->getContent()); + } +} diff --git a/tests/Resource/EventsResourceTest.php b/tests/Resource/EventsResourceTest.php new file mode 100644 index 0000000..e713ee6 --- /dev/null +++ b/tests/Resource/EventsResourceTest.php @@ -0,0 +1,120 @@ +accountId = 'ACM123'; + } + + public function testGet(): void + { + $callbackId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 200, + ['response' => ['result' => ['callback' => ['callbackid' => $callbackId]]]] + ); + + $resource = new EventsResource($mockHttpClient, 'events/callbacks', Callback::class, CallbackList::class); + $callback = $resource->get($this->accountId, $callbackId); + + $this->assertSame($callbackId, $callback->callbackId); + + $request = $mockHttpClient->getLastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/events/account/ACM123/events/callbacks/12345', $request->getRequestTarget()); + } + + public function testGetNotFoundError(): void + { + $callbackId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 404, + [ + 'code' => 5, + 'message' => 'Requested resource could not be found.', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.rpc.Help', + 'links' => [ + 'description' => 'API Documentation', + 'url' => 'https://www.freshbooks.com/api/webhooks', + ] + ] + ] + ] + ); + + $resource = new EventsResource($mockHttpClient, 'events/callbacks', Callback::class, CallbackList::class); + + try { + $resource->get($this->accountId, $callbackId); + $this->fail('FreshBooksException was not thrown'); + } catch (FreshBooksException $e) { + $this->assertSame('Requested resource could not be found.', $e->getMessage()); + $this->assertSame(404, $e->getCode()); + $this->assertNull($e->getErrorCode()); + $this->assertSame([], $e->getErrorDetails()); + } + } + + public function testCreateValidationError(): void + { + $callbackId = 12345; + $mockHttpClient = $this->getMockHttpClient( + 400, + [ + 'code' => 3, + 'message' => 'Invalid data in this request.', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.rpc.BadRequest', + 'fieldViolations' => [ + [ + 'field' => 'event', + 'description' => 'Value error, Unrecognized event.' + ] + ] + ], + [ + '@type' => 'type.googleapis.com/google.rpc.Help', + 'links' => [ + 'description' => 'API Documentation', + 'url' => 'https://www.freshbooks.com/api/webhooks', + ] + ] + ] + ] + ); + + $resource = new EventsResource($mockHttpClient, 'events/callbacks', Callback::class, CallbackList::class); + + try { + $resource->create($this->accountId, data: []); + $this->fail('FreshBooksException was not thrown'); + } catch (FreshBooksException $e) { + $this->assertSame('Invalid data in this request.', $e->getMessage()); + $this->assertSame(400, $e->getCode()); + $this->assertNull($e->getErrorCode()); + $this->assertSame([[ + 'field' => 'event', + 'description' => 'Value error, Unrecognized event.' + ]], $e->getErrorDetails()); + } + } +}