Skip to content

Commit

Permalink
♻️ Handle new accounting and events errors
Browse files Browse the repository at this point in the history
The latest API changes revert the "new" accounting errors and have changed the events errors.
  • Loading branch information
Andrew McIntosh committed Apr 2, 2024
1 parent 52351dc commit 81356b3
Show file tree
Hide file tree
Showing 9 changed files with 32 additions and 117 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

- Add Ledger Accounts resource
- Properly handle some project error messages
- `access_token_expires_at` is now set as UTC
- `access_token_expires_at` is now set as UTC
- Handle new API version accounting and webhook event errors

## 1.2.1

Expand Down
32 changes: 8 additions & 24 deletions freshbooks/api/accounting.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from decimal import Decimal
from types import SimpleNamespace
from typing import Any, List, Optional, Tuple, Union
from typing import Any, List, Optional, Tuple

from freshbooks.api.resource import HttpVerbs, Resource
from freshbooks.builders import Builder
Expand Down Expand Up @@ -28,30 +28,14 @@ def _get_url(self, account_id: str, resource_id: Optional[int] = None) -> str:
self.base_url, account_id, self.accounting_path, resource_id)
return "{}/accounting/account/{}/{}".format(self.base_url, account_id, self.accounting_path)

def _extract_error(self, errors: Union[list, dict]) -> Tuple[str, int, Optional[List[dict]]]:
if isinstance(errors, list):
return errors[0]["message"], int(errors[0]["errno"]), errors
return errors["message"], int(errors["errno"]), None # pragma: no cover

def _extract_new_error(self, response_data: dict) -> Tuple[str, Optional[int], Optional[List[dict]]]:
message = response_data["message"]
code = None
details = []
for detail in response_data.get("details", []):
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo": # pragma: no branch
code = int(detail.get("reason"))
if detail.get("metadata"): # pragma: no branch
details.append(detail["metadata"])
if detail.get("metadata", {}).get("message"): # pragma: no branch
message = detail["metadata"]["message"]
return message, code, details

def _handle_error(self, response_data: dict) -> Tuple[str, Optional[int], Optional[List[dict]]]:
if response_data.get("response", {}).get("errors"):
return self._extract_error(response_data["response"]["errors"])
elif response_data.get("message") and response_data.get("code"):
return self._extract_new_error(response_data)
return "Unknown error", None, None # pragma: no cover
errors = response_data.get("response", {}).get("errors")
if not errors:
return "Unknown error", None, None # pragma: no cover
elif isinstance(errors, list):
return errors[0]["message"], int(errors[0]["errno"]), errors
else:
return errors["message"], int(errors["errno"]), None # pragma: no cover

def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any:
response = self._send_request(url, method, data)
Expand Down
19 changes: 4 additions & 15 deletions freshbooks/api/events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from typing import Tuple
from decimal import Decimal
from typing import Any, List, Optional
from typing import Any, Optional

from freshbooks.api.accounting import AccountingResource
from freshbooks.api.resource import HttpVerbs
Expand All @@ -21,18 +20,6 @@ def _get_url(self, account_id: str, resource_id: Optional[int] = None) -> str:
self.base_url, account_id, self.accounting_path, resource_id)
return "{}/events/account/{}/{}".format(self.base_url, account_id, self.accounting_path)

def _extract_error_details(self, errors: dict) -> Tuple[str, Optional[int], Optional[List[dict]]]:
if not errors: # pragma: no cover
return "Unknown error", None, None

details = []
for detail in errors.get("details", []):
if detail.get("@type") == "type.googleapis.com/google.rpc.BadRequest" and detail.get('fieldViolations'):
for field in detail.get('fieldViolations'):
details.append(field)

return errors["message"], int(errors["code"]), details

def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any:
response = self._send_request(url, method, data)

Expand All @@ -49,7 +36,9 @@ def _request(self, url: str, method: str, data: Optional[dict] = None) -> Any:
raise FreshBooksError(status, "Failed to parse response", raw_response=response.text)

if status >= 400:
error_message, error_code, error_details = self._extract_error_details(content)
error_message = content.get("message", "Unknown error")
error_code = content.get("errno")
error_details = content.get("details", [])
raise FreshBooksError(
status, error_message, error_code=error_code, error_details=error_details, raw_response=content
)
Expand Down
28 changes: 4 additions & 24 deletions tests/fixtures/create_callback_response__validation_error.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,8 @@
{
"code": 3,
"message": "Invalid data in this request.",
"errno": 422,
"message": "The request was well-formed but was unable to be followed due to semantic errors.",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "event",
"description": "Unrecognized event."
},
{
"field": "uri",
"description": "Not a well-formed URL."
}
]
},
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [
{
"description": "API Documentation",
"url": "https://www.freshbooks.com/api/webhooks"
}
]
}
"event: Value error, Unrecognized event.",
"uri: Input should be a valid URL, relative URL without a base"
]
}
5 changes: 2 additions & 3 deletions tests/fixtures/get_callback_response__no_auth.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"code": 16,
"message": "The request token failed to authenticate or validate.",
"details": []
"errno": 401,
"message": "invalid_token: The request token failed to authenticate or validate."
}
5 changes: 2 additions & 3 deletions tests/fixtures/get_callback_response__not_found.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"code": 5,
"message": "Requested resource could not be found.",
"details": []
"errno": 404,
"message": "Requested resource could not be found."
}
17 changes: 0 additions & 17 deletions tests/fixtures/get_client_response__not_found_new.json

This file was deleted.

22 changes: 1 addition & 21 deletions tests/test_accounting_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def test_get_client__sub_lists(self):
assert client.grand_total_balance == []

@httpretty.activate
def test_get_client__not_found_old_error(self):
def test_get_client__not_found_error(self):
client_id = 12345
url = "{}/accounting/account/{}/users/clients/{}".format(API_BASE_URL, self.account_id, client_id)
httpretty.register_uri(
Expand All @@ -139,26 +139,6 @@ def test_get_client__not_found_old_error(self):
"value": "12345"
}]

@httpretty.activate
def test_get_client__not_found_new_error(self):
client_id = 12345
url = "{}/accounting/account/{}/users/clients/{}".format(API_BASE_URL, self.account_id, client_id)
httpretty.register_uri(
httpretty.GET,
url,
body=json.dumps(get_fixture("get_client_response__not_found_new")),
status=404
)
try:
self.freshBooksClient.clients.get(self.account_id, client_id)
except FreshBooksError as e:
assert str(e) == "Client not found."
assert e.status_code == 404
assert e.error_code == 1012
assert e.error_details == [
{"field": "userid", "message": "Client not found.", "object": "client", "value": "12345"}
]

@httpretty.activate
def test_get_client__bad_response(self):
client_id = 12345
Expand Down
18 changes: 9 additions & 9 deletions tests/test_events_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def test_get_callback__not_found(self):
except FreshBooksError as e:
assert str(e) == "Requested resource could not be found."
assert e.status_code == 404
assert e.error_code == 5
assert e.raw_response == {"code": 5, "message": "Requested resource could not be found.", "details": []}
assert e.error_code == 404
assert e.raw_response == {"errno": 404, "message": "Requested resource could not be found."}

@httpretty.activate
def test_get_callback__no_auth(self):
Expand All @@ -71,11 +71,11 @@ def test_get_callback__no_auth(self):
try:
self.freshBooksClient.callbacks.get(self.account_id, callback_id)
except FreshBooksError as e:
assert str(e) == "The request token failed to authenticate or validate."
assert str(e) == "invalid_token: The request token failed to authenticate or validate."
assert e.status_code == 404
assert e.error_code == 16
assert e.error_code == 401
assert e.raw_response == {
"code": 16, "message": "The request token failed to authenticate or validate.", "details": []
"errno": 401, "message": "invalid_token: The request token failed to authenticate or validate."
}

@httpretty.activate
Expand Down Expand Up @@ -202,12 +202,12 @@ def test_create_callback__validation_error(self):
try:
self.freshBooksClient.callbacks.create(self.account_id, payload)
except FreshBooksError as e:
assert str(e) == "Invalid data in this request."
assert str(e) == "The request was well-formed but was unable to be followed due to semantic errors."
assert e.status_code == 400
assert e.error_code == 3
assert e.error_code == 422
assert e.error_details == [
{"field": "event", "description": "Unrecognized event."},
{"field": "uri", "description": "Not a well-formed URL."}
"event: Value error, Unrecognized event.",
"uri: Input should be a valid URL, relative URL without a base"
]
assert e.raw_response == response

Expand Down

0 comments on commit 81356b3

Please sign in to comment.