-
Notifications
You must be signed in to change notification settings - Fork 73
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #43 from rheinwerk-verlag/feature/ecommerce_bridge
Ecommerce Bridge API
- Loading branch information
Showing
8 changed files
with
470 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
""" | ||
hubspot ecommerce bridge api | ||
""" | ||
from collections.abc import Sequence | ||
from typing import List | ||
|
||
from hubspot3 import logging_helper | ||
from hubspot3.base import BaseClient | ||
|
||
|
||
ECOMMERCE_BRIDGE_API_VERSION = "2" | ||
MAX_ECOMMERCE_BRIDGE_SYNC_MESSAGES = 200 # Maximum number of sync messages per request. | ||
MAX_ECOMMERCE_BRIDGE_SYNC_ERRORS = 200 # Maximum number of errors per response. | ||
|
||
|
||
class EcommerceBridgeClient(BaseClient): | ||
""" | ||
The hubspot3 Ecommerce Bridge client uses the _make_request method to call the | ||
API for data. It returns a python object translated from the json returned | ||
""" | ||
|
||
class ObjectType: | ||
"""object type enum""" | ||
|
||
CONTACT = "CONTACT" | ||
DEAL = "DEAL" | ||
PRODUCT = "PRODUCT" | ||
LINE_ITEM = "LINE_ITEM" | ||
|
||
class Action: | ||
"""action enum""" | ||
|
||
DELETE = "DELETE" | ||
UPSERT = "UPSERT" | ||
|
||
class ErrorType: | ||
"""error type enum""" | ||
|
||
INACTIVE_PORTAL = "INACTIVE_PORTAL" | ||
NO_SYNC_SETTINGS = "NO_SYNC_SETTINGS" | ||
SETTINGS_NOT_ENABLED = "SETTINGS_NOT_ENABLED" | ||
NO_MAPPINGS_DEFINED = "NO_MAPPINGS_DEFINED" | ||
MISSING_REQUIRED_PROPERTY = "MISSING_REQUIRED_PROPERTY" | ||
NO_PROPERTIES_DEFINED = "NO_PROPERTIES_DEFINED" | ||
INVALID_ASSOCIATION_PROPERTY = "INVALID_ASSOCIATION_PROPERTY" | ||
INVALID_DEAL_STAGE = "INVALID_DEAL_STAGE" | ||
INVALID_EMAIL_ADDRESS = "INVALID_EMAIL_ADDRESS" | ||
INVALID_ENUM_PROPERTY = "INVALID_ENUM_PROPERTY" | ||
UNKNOWN_ERROR = "UNKNOWN_ERROR" | ||
|
||
def __init__(self, *args, **kwargs): | ||
"""initialize an ecommerce bridge client""" | ||
super(EcommerceBridgeClient, self).__init__(*args, **kwargs) | ||
self.log = logging_helper.get_log("hubspot3.ecommerce_bridge") | ||
|
||
def _get_path(self, subpath): | ||
return "extensions/ecomm/v{}/{}".format(ECOMMERCE_BRIDGE_API_VERSION, subpath) | ||
|
||
def send_sync_messages( | ||
self, object_type: str, messages: Sequence, store_id: str = "default", **options | ||
) -> None: | ||
""" | ||
Send multiple ecommerce sync messages for the given object type and store ID. | ||
If the number of sync messages exceeds the maximum number of sync messages per request, | ||
the messages will automatically be split up into appropriately sized requests. | ||
See: https://developers.hubspot.com/docs/methods/ecommerce/v2/send-sync-messages | ||
""" | ||
# Break the messages down into chunks that do not contain more than the maximum number | ||
# of allowed sync messages per request. | ||
chunks = [ | ||
list(messages[i:i + MAX_ECOMMERCE_BRIDGE_SYNC_MESSAGES]) | ||
for i in range(0, len(messages), MAX_ECOMMERCE_BRIDGE_SYNC_MESSAGES) | ||
] | ||
for chunk in chunks: | ||
data = {"objectType": object_type, "storeId": store_id, "messages": chunk} | ||
self._call("sync/messages", data=data, method="PUT", **options) | ||
|
||
def _get_sync_errors( | ||
self, | ||
subpath: str, | ||
include_resolved: bool = False, | ||
error_type: str = None, | ||
object_type: str = None, | ||
limit: int = None, | ||
**options | ||
) -> List: | ||
"""Internal method to retrieve sync errors from an endpoint.""" | ||
# Build the common parameters for all requests. | ||
query_limit = min( | ||
limit or MAX_ECOMMERCE_BRIDGE_SYNC_ERRORS, MAX_ECOMMERCE_BRIDGE_SYNC_ERRORS | ||
) | ||
common_params = { | ||
"includeResolved": str(include_resolved).lower(), | ||
"limit": query_limit, | ||
} | ||
if error_type: | ||
common_params["errorType"] = error_type | ||
if object_type: | ||
common_params["objectType"] = object_type | ||
|
||
# Potentially perform multiple requests until the given limit is reached or until all | ||
# errors have been retrieved. | ||
errors = [] | ||
finished = False | ||
page = 1 | ||
while not finished: | ||
batch = self._call( | ||
subpath, method="GET", params=dict(common_params, page=page), **options | ||
) | ||
errors.extend(batch["results"]) | ||
# The "paging" attribute is only present if there are more pages. | ||
finished = "paging" not in batch or ( | ||
limit is not None and len(errors) >= limit | ||
) | ||
# The endpoints use some weird kind of pagination where the page parameter is | ||
# essentially an offset: page 1 starts with record 1, page 2 with record 2, etc. | ||
# regardless of the limit parameter. Therefore, the next page must be determined by | ||
# adding the query limit (instead of 1) to get the next set of errors that weren't | ||
# already part of the current batch. | ||
page += query_limit | ||
|
||
return errors[:limit] | ||
|
||
def get_sync_errors_for_account( | ||
self, | ||
include_resolved: bool = False, | ||
error_type: str = None, | ||
object_type: str = None, | ||
limit: int = None, | ||
**options | ||
) -> List: | ||
""" | ||
Retrieve a list of error dictionaries for the account that is associated with the | ||
credentials used for the connection, optionally filtered/limited, and ordered by recency. | ||
:see: https://developers.hubspot.com/docs/methods/ecommerce/v2/get-all-sync-errors-for-a-specific-account # noqa | ||
""" | ||
return self._get_sync_errors( | ||
"sync/errors/portal", | ||
include_resolved=include_resolved, | ||
error_type=error_type, | ||
object_type=object_type, | ||
limit=limit, | ||
**options | ||
) | ||
|
||
def get_sync_errors_for_app( | ||
self, | ||
app_id: int, | ||
include_resolved: bool = False, | ||
error_type: str = None, | ||
object_type: str = None, | ||
limit: int = None, | ||
**options | ||
) -> List: | ||
""" | ||
Retrieve a list of error dictionaries for the app with the given ID, optionally | ||
filtered/limited, and ordered by recency. | ||
:see: https://developers.hubspot.com/docs/methods/ecommerce/v2/get-all-sync-errors-for-an-app # noqa | ||
""" | ||
return self._get_sync_errors( | ||
"sync/errors/app/{app_id}".format(app_id=app_id), | ||
include_resolved=include_resolved, | ||
error_type=error_type, | ||
object_type=object_type, | ||
limit=limit, | ||
**options | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,86 @@ | ||
""" | ||
configure pytest | ||
""" | ||
from http.client import HTTPSConnection | ||
import json | ||
from urllib.parse import urlencode | ||
|
||
from unittest.mock import MagicMock, Mock | ||
import pytest | ||
|
||
|
||
@pytest.fixture(scope="session", autouse=True) | ||
def before_all(request): | ||
"""test setup""" | ||
request.addfinalizer(after_all) | ||
@pytest.fixture | ||
def mock_connection(): | ||
""" | ||
A mock connection object that can be used in place of HTTP(S)Connection objects to avoid to | ||
actually perform requests. Offers some utilities to check if certain requests were performed. | ||
""" | ||
connection = Mock(spec=HTTPSConnection, host="api.hubapi.com", timeout=10) | ||
|
||
def assert_num_requests(number): | ||
"""Assert that a certain number of requests was made.""" | ||
assert connection.request.call_count == number | ||
|
||
connection.assert_num_requests = assert_num_requests | ||
|
||
def assert_has_request(method, url, data=None): | ||
""" | ||
Assert that at least one request with the exact combination of method, URL and body data | ||
was performed. | ||
""" | ||
data = json.dumps(data) if data else None | ||
for args, kwargs in connection.request.call_args_list: | ||
if args[0] == method and args[1] == url and args[2] == data: | ||
break | ||
else: | ||
raise AssertionError( | ||
"No {method} request to URL '{url}' with data '{data}' was performed.'".format( | ||
method=method, url=url, data=data | ||
) | ||
) | ||
|
||
connection.assert_has_request = assert_has_request | ||
|
||
def assert_query_parameters_in_request(**params): | ||
""" | ||
Assert that at least one request using all of the given query parameters was performed. | ||
""" | ||
for args, kwargs in connection.request.call_args_list: | ||
url = args[1] | ||
if all( | ||
urlencode({name: value}, doseq=True) in url | ||
for name, value in params.items() | ||
): | ||
break | ||
else: | ||
raise AssertionError( | ||
"No request contains all given query parameters: {}".format(params) | ||
) | ||
|
||
connection.assert_query_parameters_in_request = assert_query_parameters_in_request | ||
|
||
def set_response(status_code, body): | ||
"""Set the response status code and body for all mocked requests.""" | ||
response = MagicMock(status=status_code) | ||
response.read.return_value = body | ||
connection.getresponse.return_value = response | ||
|
||
connection.set_response = set_response | ||
|
||
def set_responses(response_tuples): | ||
""" | ||
Set multiple responses for consecutive mocked requests via tuples of (status code, body). | ||
The first request will result in the first response, the second request in the second | ||
response and so on. | ||
""" | ||
responses = [] | ||
for status_code, body in response_tuples: | ||
response = MagicMock(status=status_code) | ||
response.read.return_value = body | ||
responses.append(response) | ||
connection.getresponse.side_effect = responses | ||
|
||
connection.set_responses = set_responses | ||
|
||
def after_all(): | ||
"""tear down""" | ||
pass | ||
connection.set_response(200, "") | ||
return connection |
Oops, something went wrong.