Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
2 changes: 1 addition & 1 deletion optimizely/helpers/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. '


Expand Down
27 changes: 27 additions & 0 deletions optimizely/odp/odp_event.py
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
Mat001 marked this conversation as resolved.
self.action = action,
self.identifiers = identifiers,
self.data = data
88 changes: 88 additions & 0 deletions optimizely/odp/zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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, List

import requests
from requests.exceptions import RequestException, ConnectionError, Timeout, JSONDecodeError, InvalidURL

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 sendOdpEvents(self, api_key: str, api_host: str, events: List[OdpEvent]) -> Optional[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
"""
can_retry: bool = True
Comment thread
Mat001 marked this conversation as resolved.
Outdated
url = f'{api_host}/v3/events'
request_headers = {'content-type': 'application/json', 'x-api-key': api_key}

try:
response = requests.post(url=url,
headers=request_headers,
data=json.dumps(events),
Comment thread
Mat001 marked this conversation as resolved.
Outdated
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)

response.raise_for_status()
can_retry = False

except (ConnectionError, Timeout):
self.logger.error(Errors.ODP_EVENT_FAILED.format('network error'))
# we do retry, can_retry = True
except JSONDecodeError:
Comment thread
Mat001 marked this conversation as resolved.
Outdated
self.logger.error(Errors.ODP_EVENT_FAILED.format('JSON decode error'))
can_retry = False
except InvalidURL:
self.logger.error(Errors.ODP_EVENT_FAILED.format('invalid URL'))
can_retry = False
except RequestException as err:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, but can you confirm that this catches all other exceptions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if 400 <= err.response.status_code < 500:
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
Comment thread
Mat001 marked this conversation as resolved.
Outdated
can_retry = False
else:
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
# we do retry, can_retry = True
finally:
return can_retry
Comment thread
Mat001 marked this conversation as resolved.
Outdated
26 changes: 26 additions & 0 deletions tests/helpers_for_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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 requests import Response
from typing import Optional


def fake_server_response(status_code: Optional[int] = None, content: Optional[str] = None,
url: Optional[str] = None) -> Optional[Response]:
"""Mock the server response."""
response = Response()
response.status_code = status_code
if content:
response._content = content.encode('utf-8')
response.url = url
return response
46 changes: 19 additions & 27 deletions tests/test_odp_zaius_graphql_api_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
import json
from unittest import mock

from requests import Response
from requests import exceptions as request_exception
from optimizely.helpers.enums import OdpGraphQLApiConfig

from tests.helpers_for_tests import fake_server_response
from optimizely.helpers.enums import OdpGraphQLApiConfig
from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager
from . import base

Expand Down Expand Up @@ -50,7 +50,7 @@ def test_fetch_qualified_segments__valid_request(self):
def test_fetch_qualified_segments__success(self):
with mock.patch('requests.post') as mock_request_post:
mock_request_post.return_value = \
self.fake_server_response(status_code=200, content=self.good_response_data)
fake_server_response(status_code=200, content=self.good_response_data)

api = ZaiusGraphQLApiManager()
response = api.fetch_segments(api_key=self.api_key,
Expand All @@ -65,7 +65,7 @@ def test_fetch_qualified_segments__node_missing(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=200, content=self.node_missing_response_data)
fake_server_response(status_code=200, content=self.node_missing_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -81,8 +81,8 @@ def test_fetch_qualified_segments__mixed_missing_keys(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=200,
content=self.mixed_missing_keys_response_data)
fake_server_response(status_code=200,
content=self.mixed_missing_keys_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -97,7 +97,7 @@ def test_fetch_qualified_segments__mixed_missing_keys(self):
def test_fetch_qualified_segments__success_with_empty_segments(self):
with mock.patch('requests.post') as mock_request_post:
mock_request_post.return_value = \
self.fake_server_response(status_code=200, content=self.good_empty_response_data)
fake_server_response(status_code=200, content=self.good_empty_response_data)

api = ZaiusGraphQLApiManager()
response = api.fetch_segments(api_key=self.api_key,
Expand All @@ -112,8 +112,8 @@ def test_fetch_qualified_segments__invalid_identifier(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=200,
content=self.invalid_identifier_response_data)
fake_server_response(status_code=200,
content=self.invalid_identifier_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -129,7 +129,7 @@ def test_fetch_qualified_segments__other_exception(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=200, content=self.other_exception_response_data)
fake_server_response(status_code=200, content=self.other_exception_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -145,7 +145,7 @@ def test_fetch_qualified_segments__bad_response(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=200, content=self.bad_response_data)
fake_server_response(status_code=200, content=self.bad_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -161,7 +161,7 @@ def test_fetch_qualified_segments__name_invalid(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=200, content=self.name_invalid_response_data)
fake_server_response(status_code=200, content=self.name_invalid_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -176,7 +176,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 = 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,
Expand All @@ -191,7 +192,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 = 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,
Expand Down Expand Up @@ -221,7 +223,7 @@ def test_fetch_qualified_segments__network_error(self):
def test_fetch_qualified_segments__400(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=403, url=self.api_host)
mock_request_post.return_value = fake_server_response(status_code=403, url=self.api_host)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -241,7 +243,7 @@ def test_fetch_qualified_segments__400(self):
def test_fetch_qualified_segments__500(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)
mock_request_post.return_value = fake_server_response(status_code=500, url=self.api_host)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -265,17 +267,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 = """
{
Expand Down
100 changes: 100 additions & 0 deletions tests/test_odp_zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 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 tests.helpers_for_tests import fake_server_response
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.sendOdpEvents(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 testSendOdpEvents_success(self):
with mock.patch('requests.post') as mock_request_post:
mock_request_post.return_value = \
fake_server_response(status_code=200)

api = ZaiusRestApiManager()
response = api.sendOdpEvents(api_key=self.api_key,
Comment thread
Mat001 marked this conversation as resolved.
Outdated
api_host=self.api_host,
events=self.events) # content of events doesn't matter for the test

self.assertFalse(response)

def testSendOdpEvents_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)
response = api.sendOdpEvents(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertTrue(response)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed (network error).')

def testSendOdpEvents_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 = fake_server_response(status_code=403, url=self.api_host)

api = ZaiusRestApiManager(logger=mock_logger)
response = api.sendOdpEvents(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertFalse(response)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed (403 Client Error: None for url: test-host).')

def testSendOdpEvents_500_retry(self):
with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
mock_request_post.return_value = fake_server_response(status_code=500, url=self.api_host)

api = ZaiusRestApiManager(logger=mock_logger)
response = api.sendOdpEvents(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertTrue(response)
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).')