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

feat: add odp rest api manager #398

Merged
merged 9 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from all 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,
Mat001 marked this conversation as resolved.
Show resolved Hide resolved
self.action = action,
self.identifiers = identifiers,
self.data = data
94 changes: 94 additions & 0 deletions optimizely/odp/zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -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:
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 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
18 changes: 18 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

import json
import unittest
from typing import Optional

from requests import Response

from optimizely import optimizely

Expand All @@ -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',
Expand Down
21 changes: 6 additions & 15 deletions tests/test_odp_zaius_graphql_api_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 = """
{
Expand Down
139 changes: 139 additions & 0 deletions tests/test_odp_zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -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"}]}}'