Skip to content

feat: add odp rest api manager #398

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

Merged
merged 9 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,
self.action = action,
self.identifiers = identifiers,
self.data = data
86 changes: 86 additions & 0 deletions optimizely/odp/zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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
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),
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:
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
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
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))
can_retry = False
else:
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
# we do retry, can_retry = True
finally:
return can_retry
25 changes: 25 additions & 0 deletions tests/helpers_for_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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: int = None, content: bytes = None, url: 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,
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).')