diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index a82d6a98..ab63d1e3 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -122,7 +122,7 @@ class Errors: 'This version of the Python SDK does not support the given datafile version: "{}".') INVALID_SEGMENT_IDENTIFIER = 'Audience segments fetch failed (invalid identifier).' FETCH_SEGMENTS_FAILED = 'Audience segments fetch failed ({}).' - ODP_EVENT_FAILED = 'ODP event send failed (invalid url).' + ODP_EVENT_FAILED = 'ODP event send failed ({}).' ODP_NOT_ENABLED = 'ODP is not enabled. ' diff --git a/optimizely/odp/odp_event.py b/optimizely/odp/odp_event.py new file mode 100644 index 00000000..23015db5 --- /dev/null +++ b/optimizely/odp/odp_event.py @@ -0,0 +1,27 @@ +# Copyright 2022, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any, Dict + + +class OdpEvent: + """ Representation of an odp event which can be sent to the Optimizely odp platform. """ + + def __init__(self, type: str, action: str, + identifiers: Dict[str, str], data: Dict[str, Any]) -> None: + self.type = type, + self.action = action, + self.identifiers = identifiers, + self.data = data diff --git a/optimizely/odp/zaius_rest_api_manager.py b/optimizely/odp/zaius_rest_api_manager.py new file mode 100644 index 00000000..9cbe2638 --- /dev/null +++ b/optimizely/odp/zaius_rest_api_manager.py @@ -0,0 +1,94 @@ +# Copyright 2022, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from typing import Optional + +import requests +from requests.exceptions import RequestException, ConnectionError, Timeout + +from optimizely import logger as optimizely_logger +from optimizely.helpers.enums import Errors, OdpRestApiConfig +from optimizely.odp.odp_event import OdpEvent + +""" + ODP REST Events API + - https://api.zaius.com/v3/events + - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" + + [Event Request] + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d + '{"type":"fullstack","action":"identified","identifiers":{"vuid": "123","fs_user_id": "abc"}, + "data":{"idempotence_id":"xyz","source":"swift-sdk"}}' https://api.zaius.com/v3/events + [Event Response] + {"title":"Accepted","status":202,"timestamp":"2022-06-30T20:59:52.046Z"} +""" + + +class ZaiusRestApiManager: + """Provides an internal service for ODP event REST api access.""" + + def __init__(self, logger: Optional[optimizely_logger.Logger] = None): + self.logger = logger or optimizely_logger.NoOpLogger() + + def send_odp_events(self, api_key: str, api_host: str, events: list[OdpEvent]) -> bool: + """ + Dispatch the event being represented by the OdpEvent object. + + Args: + api_key: public api key + api_host: domain url of the host + events: list of odp events to be sent to optimizely's odp platform. + + Returns: + retry is True - if network or server error (5xx), otherwise False + """ + should_retry = False + url = f'{api_host}/v3/events' + request_headers = {'content-type': 'application/json', 'x-api-key': api_key} + + try: + payload_dict = json.dumps(events) + except TypeError as err: + self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) + return should_retry + + try: + response = requests.post(url=url, + headers=request_headers, + data=payload_dict, + timeout=OdpRestApiConfig.REQUEST_TIMEOUT) + + response.raise_for_status() + + except (ConnectionError, Timeout): + self.logger.error(Errors.ODP_EVENT_FAILED.format('network error')) + # retry on network errors + should_retry = True + except RequestException as err: + if err.response is not None: + if 400 <= err.response.status_code < 500: + # log 4xx + self.logger.error(Errors.ODP_EVENT_FAILED.format(err.response.text)) + else: + # log 5xx + self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) + # retry on 500 exceptions + should_retry = True + else: + # log exceptions without response body (i.e. invalid url) + self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) + + return should_retry diff --git a/tests/base.py b/tests/base.py index e793d1c3..65ae1fe1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -13,6 +13,9 @@ import json import unittest +from typing import Optional + +from requests import Response from optimizely import optimizely @@ -28,6 +31,21 @@ def assertStrictTrue(self, to_assert): def assertStrictFalse(self, to_assert): self.assertIs(to_assert, False) + def fake_server_response(self, status_code: Optional[int] = None, + content: Optional[str] = None, + url: Optional[str] = None) -> Response: + """Mock the server response.""" + response = Response() + + if status_code: + response.status_code = status_code + if content: + response._content = content.encode('utf-8') + if url: + response.url = url + + return response + def setUp(self, config_dict='config_dict'): self.config_dict = { 'revision': '42', diff --git a/tests/test_odp_zaius_graphql_api_manager.py b/tests/test_odp_zaius_graphql_api_manager.py index cb728962..5ac85b2a 100644 --- a/tests/test_odp_zaius_graphql_api_manager.py +++ b/tests/test_odp_zaius_graphql_api_manager.py @@ -14,10 +14,9 @@ import json from unittest import mock -from requests import Response from requests import exceptions as request_exception -from optimizely.helpers.enums import OdpGraphQLApiConfig +from optimizely.helpers.enums import OdpGraphQLApiConfig from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager from . import base @@ -176,7 +175,8 @@ def test_fetch_qualified_segments__name_invalid(self): def test_fetch_qualified_segments__invalid_key(self): with mock.patch('requests.post') as mock_request_post, \ mock.patch('optimizely.logger') as mock_logger: - mock_request_post.return_value.json.return_value = json.loads(self.invalid_edges_key_response_data) + mock_request_post.return_value = self.fake_server_response(status_code=200, + content=self.invalid_edges_key_response_data) api = ZaiusGraphQLApiManager(logger=mock_logger) api.fetch_segments(api_key=self.api_key, @@ -191,7 +191,8 @@ def test_fetch_qualified_segments__invalid_key(self): def test_fetch_qualified_segments__invalid_key_in_error_body(self): with mock.patch('requests.post') as mock_request_post, \ mock.patch('optimizely.logger') as mock_logger: - mock_request_post.return_value.json.return_value = json.loads(self.invalid_key_for_error_response_data) + mock_request_post.return_value = self.fake_server_response(status_code=200, + content=self.invalid_key_for_error_response_data) api = ZaiusGraphQLApiManager(logger=mock_logger) api.fetch_segments(api_key=self.api_key, @@ -265,17 +266,7 @@ def test_make_subset_filter(self): self.assertEqual("(subset:[\"a\", \"b\", \"c\"])", api.make_subset_filter(["a", "b", "c"])) self.assertEqual("(subset:[\"a\", \"b\", \"don't\"])", api.make_subset_filter(["a", "b", "don't"])) - # fake server response function and test json responses - - @staticmethod - def fake_server_response(status_code=None, content=None, url=None): - """Mock the server response.""" - response = Response() - response.status_code = status_code - if content: - response._content = content.encode('utf-8') - response.url = url - return response + # test json responses good_response_data = """ { diff --git a/tests/test_odp_zaius_rest_api_manager.py b/tests/test_odp_zaius_rest_api_manager.py new file mode 100644 index 00000000..e7327d6f --- /dev/null +++ b/tests/test_odp_zaius_rest_api_manager.py @@ -0,0 +1,139 @@ +# Copyright 2022, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http:#www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from unittest import mock + +from requests import exceptions as request_exception + +from optimizely.helpers.enums import OdpRestApiConfig +from optimizely.odp.zaius_rest_api_manager import ZaiusRestApiManager +from . import base + + +class ZaiusRestApiManagerTest(base.BaseTest): + user_key = "vuid" + user_value = "test-user-value" + api_key = "test-api-key" + api_host = "test-host" + + events = [ + {"type": "t1", "action": "a1", "identifiers": {"id-key-1": "id-value-1"}, "data": {"key-1": "value1"}}, + {"type": "t2", "action": "a2", "identifiers": {"id-key-2": "id-value-2"}, "data": {"key-2": "value2"}}, + ] + + def test_send_odp_events__valid_request(self): + with mock.patch('requests.post') as mock_request_post: + api = ZaiusRestApiManager() + api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=self.events) + + request_headers = {'content-type': 'application/json', 'x-api-key': self.api_key} + mock_request_post.assert_called_once_with(url=self.api_host + "/v3/events", + headers=request_headers, + data=json.dumps(self.events), + timeout=OdpRestApiConfig.REQUEST_TIMEOUT) + + def test_send_odp_ovents_success(self): + with mock.patch('requests.post') as mock_request_post: + # no need to mock url and content because we're not returning the response + mock_request_post.return_value = self.fake_server_response(status_code=200) + + api = ZaiusRestApiManager() + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=self.events) # content of events doesn't matter for the test + + self.assertFalse(should_retry) + + def test_send_odp_events_invalid_json_no_retry(self): + events = {1, 2, 3} # using a set to trigger JSON-not-serializable error + + with mock.patch('requests.post') as mock_request_post, \ + mock.patch('optimizely.logger') as mock_logger: + api = ZaiusRestApiManager(logger=mock_logger) + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=events) + + self.assertFalse(should_retry) + mock_request_post.assert_not_called() + mock_logger.error.assert_called_once_with( + 'ODP event send failed (Object of type set is not JSON serializable).') + + def test_send_odp_events_invalid_url_no_retry(self): + invalid_url = 'https://*api.zaius.com' + + with mock.patch('requests.post', + side_effect=request_exception.InvalidURL('Invalid URL')) as mock_request_post, \ + mock.patch('optimizely.logger') as mock_logger: + api = ZaiusRestApiManager(logger=mock_logger) + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=invalid_url, + events=self.events) + + self.assertFalse(should_retry) + mock_request_post.assert_called_once() + mock_logger.error.assert_called_once_with('ODP event send failed (Invalid URL).') + + def test_send_odp_events_network_error_retry(self): + with mock.patch('requests.post', + side_effect=request_exception.ConnectionError('Connection error')) as mock_request_post, \ + mock.patch('optimizely.logger') as mock_logger: + api = ZaiusRestApiManager(logger=mock_logger) + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=self.events) + + self.assertTrue(should_retry) + mock_request_post.assert_called_once() + mock_logger.error.assert_called_once_with('ODP event send failed (network error).') + + def test_send_odp_events_400_no_retry(self): + with mock.patch('requests.post') as mock_request_post, \ + mock.patch('optimizely.logger') as mock_logger: + mock_request_post.return_value = self.fake_server_response(status_code=400, + url=self.api_host, + content=self.failure_response_data) + + api = ZaiusRestApiManager(logger=mock_logger) + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=self.events) + + self.assertFalse(should_retry) + mock_request_post.assert_called_once() + mock_logger.error.assert_called_once_with('ODP event send failed ({"title":"Bad Request","status":400,' + '"timestamp":"2022-07-01T20:44:00.945Z","detail":{"invalids":' + '[{"event":0,"message":"missing \'type\' field"}]}}).') + + def test_send_odp_events_500_retry(self): + with mock.patch('requests.post') as mock_request_post, \ + mock.patch('optimizely.logger') as mock_logger: + mock_request_post.return_value = self.fake_server_response(status_code=500, url=self.api_host) + + api = ZaiusRestApiManager(logger=mock_logger) + should_retry = api.send_odp_events(api_key=self.api_key, + api_host=self.api_host, + events=self.events) + + self.assertTrue(should_retry) + mock_request_post.assert_called_once() + mock_logger.error.assert_called_once_with('ODP event send failed (500 Server Error: None for url: test-host).') + + # test json responses + success_response_data = '{"title":"Accepted","status":202,"timestamp":"2022-07-01T16:04:06.786Z"}' + + failure_response_data = '{"title":"Bad Request","status":400,"timestamp":"2022-07-01T20:44:00.945Z",' \ + '"detail":{"invalids":[{"event":0,"message":"missing \'type\' field"}]}}'