Skip to content

Commit

Permalink
Merge pull request #43 from rheinwerk-verlag/feature/ecommerce_bridge
Browse files Browse the repository at this point in the history
Ecommerce Bridge API
  • Loading branch information
jpetrucciani authored Jun 28, 2019
2 parents 932731a + a679f50 commit 8d40896
Show file tree
Hide file tree
Showing 8 changed files with 470 additions and 21 deletions.
7 changes: 7 additions & 0 deletions hubspot3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ def deals(self):

return DealsClient(**self.auth, **self.options)

@property
def ecommerce_bridge(self):
"""returns a hubspot3 ecommerce bridge client"""
from hubspot3.ecommerce_bridge import EcommerceBridgeClient

return EcommerceBridgeClient(**self.auth, **self.options)

@property
def engagements(self):
"""returns a hubspot3 engagements client"""
Expand Down
6 changes: 3 additions & 3 deletions hubspot3/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,18 @@ def _replace_stdin_token(self, *args, **kwargs) -> Tuple[List, Dict]:
Replace the values of all given arguments with the JSON-parsed value
from stdin if their current value is the STDIN_TOKEN.
"""
args = list(args)
stdin_indices = [
index for index, value in enumerate(args) if value == self.STDIN_TOKEN
]
stdin_keys = [key for key, value in kwargs.items() if value == self.STDIN_TOKEN]
if stdin_indices or stdin_keys:
value = json.load(sys.stdin)
new_args = list(args)
for index in stdin_indices:
new_args[index] = value
args[index] = value
for key in stdin_keys:
kwargs[key] = value
return new_args, kwargs
return args, kwargs


def split_args() -> Tuple[List, List, List]:
Expand Down
167 changes: 167 additions & 0 deletions hubspot3/ecommerce_bridge.py
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
)
15 changes: 6 additions & 9 deletions hubspot3/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,14 @@ def __init__(self, result, request, err=None):
self.err = err

def __str__(self):
return force_utf8(self.__unicode__())

def __unicode__(self):
params = {}
request_keys = ("method", "host", "url", "data", "headers", "timeout", "body")
result_attrs = ("status", "reason", "msg", "body", "headers")
params["error"] = self.err
params["error_message"] = "Hubspot Error"
if self.result.body:
try:
result_body = json.loads(self.result.body)
result_body = json.loads(force_utf8(self.result.body))
except ValueError:
result_body = {}
params["error_message"] = result_body.get("message", "Hubspot Error")
Expand All @@ -91,20 +88,20 @@ def __unicode__(self):
for attr in result_attrs:
params["result_{}".format(attr)] = getattr(self.result, attr, "")

params = self._dict_vals_to_unicode(params)
params = self._dict_vals_to_str(params)
return self.as_str_template.format(**params)

def _dict_vals_to_unicode(self, data):
def _dict_vals_to_str(self, data):
unicode_data = {}
for key, val in list(data.items()):
for key, val in data.items():
if not val:
unicode_data[key] = ""
if isinstance(val, bytes):
unicode_data[key] = force_utf8(val)
elif isinstance(val, str):
unicode_data[key] = force_utf8(val)
unicode_data[key] = val
else:
unicode_data[key] = force_utf8(type(val))
unicode_data[key] = str(type(val))
return unicode_data


Expand Down
85 changes: 78 additions & 7 deletions hubspot3/test/conftest.py
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
Loading

0 comments on commit 8d40896

Please sign in to comment.