Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ecommerce Bridge API #43

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef3440d
Added a client for the Ecommerce Bridge API
W1ldPo1nter Jun 25, 2019
d277e2f
Added a property for the Ecommerce Bridge client to the main Hubspot3…
W1ldPo1nter Jun 25, 2019
1902fe3
Merge pull request #2 from jpetrucciani/master
W1ldPo1nter Jun 25, 2019
1e93cfc
Added a fixture for a mocked connection to simplify tests using mock …
W1ldPo1nter Jun 26, 2019
6db9481
Added tests for the Ecommerce Bridge Client
W1ldPo1nter Jun 26, 2019
8f7d960
Merge branch 'feature/ecommerce_bridge' into feature/INC-1791-1
W1ldPo1nter Jun 26, 2019
f753726
Allow to specify pytest arguments in tox calls
W1ldPo1nter Jun 26, 2019
2fefeb2
Fixed a conditionally defined variable
W1ldPo1nter Jun 26, 2019
2bf469b
Apply black code styling to the changed modules
W1ldPo1nter Jun 26, 2019
1a5ae2d
Perform the args type conversion a bit earlier for consistent return …
W1ldPo1nter Jun 26, 2019
7021576
Fixed the exception string representation for Python 3.5 and removed …
W1ldPo1nter Jun 26, 2019
63b2c9b
Re-ordered arguments to match the HubSpot docs
W1ldPo1nter Jun 26, 2019
adfb818
PEP-8 fixes
W1ldPo1nter Jun 27, 2019
2a163cc
Let assert_has_request produce a better result for debugging
W1ldPo1nter Jun 28, 2019
c7b134f
Applied black styling
W1ldPo1nter Jun 28, 2019
0359aa6
Allow parameters with multiple values in assert_query_parameters_in_r…
W1ldPo1nter Jun 28, 2019
cb643e4
Use set_responses correctly by setting it to the raw responses
W1ldPo1nter Jun 28, 2019
1b30b3a
Fixed a few typos and an import
W1ldPo1nter Jun 28, 2019
7fa03b6
Merge pull request #3 from rheinwerk-verlag/feature/INC-1791-1
rheinwerk-mp Jun 28, 2019
a679f50
Fixed a test parametrization
W1ldPo1nter Jun 28, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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