Skip to content

Commit 998dbc7

Browse files
authored
feat: add odp rest api manager (#398)
* feat: add odp rest api manager * fix: fix linting, str type * fix white space * addressed PR comments * moved helper test funciton to base.py * fix graphql tests becasue helper method moved to base.py * remove unnecessary url parsing exceptions * remove print statement * fixed type hints
1 parent 2015e55 commit 998dbc7

File tree

6 files changed

+285
-16
lines changed

6 files changed

+285
-16
lines changed

optimizely/helpers/enums.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class Errors:
122122
'This version of the Python SDK does not support the given datafile version: "{}".')
123123
INVALID_SEGMENT_IDENTIFIER = 'Audience segments fetch failed (invalid identifier).'
124124
FETCH_SEGMENTS_FAILED = 'Audience segments fetch failed ({}).'
125-
ODP_EVENT_FAILED = 'ODP event send failed (invalid url).'
125+
ODP_EVENT_FAILED = 'ODP event send failed ({}).'
126126
ODP_NOT_ENABLED = 'ODP is not enabled. '
127127

128128

optimizely/odp/odp_event.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright 2022, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from __future__ import annotations
15+
16+
from typing import Any, Dict
17+
18+
19+
class OdpEvent:
20+
""" Representation of an odp event which can be sent to the Optimizely odp platform. """
21+
22+
def __init__(self, type: str, action: str,
23+
identifiers: Dict[str, str], data: Dict[str, Any]) -> None:
24+
self.type = type,
25+
self.action = action,
26+
self.identifiers = identifiers,
27+
self.data = data
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright 2022, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from typing import Optional
18+
19+
import requests
20+
from requests.exceptions import RequestException, ConnectionError, Timeout
21+
22+
from optimizely import logger as optimizely_logger
23+
from optimizely.helpers.enums import Errors, OdpRestApiConfig
24+
from optimizely.odp.odp_event import OdpEvent
25+
26+
"""
27+
ODP REST Events API
28+
- https://api.zaius.com/v3/events
29+
- test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ"
30+
31+
[Event Request]
32+
curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d
33+
'{"type":"fullstack","action":"identified","identifiers":{"vuid": "123","fs_user_id": "abc"},
34+
"data":{"idempotence_id":"xyz","source":"swift-sdk"}}' https://api.zaius.com/v3/events
35+
[Event Response]
36+
{"title":"Accepted","status":202,"timestamp":"2022-06-30T20:59:52.046Z"}
37+
"""
38+
39+
40+
class ZaiusRestApiManager:
41+
"""Provides an internal service for ODP event REST api access."""
42+
43+
def __init__(self, logger: Optional[optimizely_logger.Logger] = None):
44+
self.logger = logger or optimizely_logger.NoOpLogger()
45+
46+
def send_odp_events(self, api_key: str, api_host: str, events: list[OdpEvent]) -> bool:
47+
"""
48+
Dispatch the event being represented by the OdpEvent object.
49+
50+
Args:
51+
api_key: public api key
52+
api_host: domain url of the host
53+
events: list of odp events to be sent to optimizely's odp platform.
54+
55+
Returns:
56+
retry is True - if network or server error (5xx), otherwise False
57+
"""
58+
should_retry = False
59+
url = f'{api_host}/v3/events'
60+
request_headers = {'content-type': 'application/json', 'x-api-key': api_key}
61+
62+
try:
63+
payload_dict = json.dumps(events)
64+
except TypeError as err:
65+
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
66+
return should_retry
67+
68+
try:
69+
response = requests.post(url=url,
70+
headers=request_headers,
71+
data=payload_dict,
72+
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)
73+
74+
response.raise_for_status()
75+
76+
except (ConnectionError, Timeout):
77+
self.logger.error(Errors.ODP_EVENT_FAILED.format('network error'))
78+
# retry on network errors
79+
should_retry = True
80+
except RequestException as err:
81+
if err.response is not None:
82+
if 400 <= err.response.status_code < 500:
83+
# log 4xx
84+
self.logger.error(Errors.ODP_EVENT_FAILED.format(err.response.text))
85+
else:
86+
# log 5xx
87+
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
88+
# retry on 500 exceptions
89+
should_retry = True
90+
else:
91+
# log exceptions without response body (i.e. invalid url)
92+
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
93+
94+
return should_retry

tests/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
import json
1515
import unittest
16+
from typing import Optional
17+
18+
from requests import Response
1619

1720
from optimizely import optimizely
1821

@@ -28,6 +31,21 @@ def assertStrictTrue(self, to_assert):
2831
def assertStrictFalse(self, to_assert):
2932
self.assertIs(to_assert, False)
3033

34+
def fake_server_response(self, status_code: Optional[int] = None,
35+
content: Optional[str] = None,
36+
url: Optional[str] = None) -> Response:
37+
"""Mock the server response."""
38+
response = Response()
39+
40+
if status_code:
41+
response.status_code = status_code
42+
if content:
43+
response._content = content.encode('utf-8')
44+
if url:
45+
response.url = url
46+
47+
return response
48+
3149
def setUp(self, config_dict='config_dict'):
3250
self.config_dict = {
3351
'revision': '42',

tests/test_odp_zaius_graphql_api_manager.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
import json
1515
from unittest import mock
1616

17-
from requests import Response
1817
from requests import exceptions as request_exception
19-
from optimizely.helpers.enums import OdpGraphQLApiConfig
2018

19+
from optimizely.helpers.enums import OdpGraphQLApiConfig
2120
from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager
2221
from . import base
2322

@@ -176,7 +175,8 @@ def test_fetch_qualified_segments__name_invalid(self):
176175
def test_fetch_qualified_segments__invalid_key(self):
177176
with mock.patch('requests.post') as mock_request_post, \
178177
mock.patch('optimizely.logger') as mock_logger:
179-
mock_request_post.return_value.json.return_value = json.loads(self.invalid_edges_key_response_data)
178+
mock_request_post.return_value = self.fake_server_response(status_code=200,
179+
content=self.invalid_edges_key_response_data)
180180

181181
api = ZaiusGraphQLApiManager(logger=mock_logger)
182182
api.fetch_segments(api_key=self.api_key,
@@ -191,7 +191,8 @@ def test_fetch_qualified_segments__invalid_key(self):
191191
def test_fetch_qualified_segments__invalid_key_in_error_body(self):
192192
with mock.patch('requests.post') as mock_request_post, \
193193
mock.patch('optimizely.logger') as mock_logger:
194-
mock_request_post.return_value.json.return_value = json.loads(self.invalid_key_for_error_response_data)
194+
mock_request_post.return_value = self.fake_server_response(status_code=200,
195+
content=self.invalid_key_for_error_response_data)
195196

196197
api = ZaiusGraphQLApiManager(logger=mock_logger)
197198
api.fetch_segments(api_key=self.api_key,
@@ -265,17 +266,7 @@ def test_make_subset_filter(self):
265266
self.assertEqual("(subset:[\"a\", \"b\", \"c\"])", api.make_subset_filter(["a", "b", "c"]))
266267
self.assertEqual("(subset:[\"a\", \"b\", \"don't\"])", api.make_subset_filter(["a", "b", "don't"]))
267268

268-
# fake server response function and test json responses
269-
270-
@staticmethod
271-
def fake_server_response(status_code=None, content=None, url=None):
272-
"""Mock the server response."""
273-
response = Response()
274-
response.status_code = status_code
275-
if content:
276-
response._content = content.encode('utf-8')
277-
response.url = url
278-
return response
269+
# test json responses
279270

280271
good_response_data = """
281272
{
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Copyright 2022, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http:#www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
import json
15+
from unittest import mock
16+
17+
from requests import exceptions as request_exception
18+
19+
from optimizely.helpers.enums import OdpRestApiConfig
20+
from optimizely.odp.zaius_rest_api_manager import ZaiusRestApiManager
21+
from . import base
22+
23+
24+
class ZaiusRestApiManagerTest(base.BaseTest):
25+
user_key = "vuid"
26+
user_value = "test-user-value"
27+
api_key = "test-api-key"
28+
api_host = "test-host"
29+
30+
events = [
31+
{"type": "t1", "action": "a1", "identifiers": {"id-key-1": "id-value-1"}, "data": {"key-1": "value1"}},
32+
{"type": "t2", "action": "a2", "identifiers": {"id-key-2": "id-value-2"}, "data": {"key-2": "value2"}},
33+
]
34+
35+
def test_send_odp_events__valid_request(self):
36+
with mock.patch('requests.post') as mock_request_post:
37+
api = ZaiusRestApiManager()
38+
api.send_odp_events(api_key=self.api_key,
39+
api_host=self.api_host,
40+
events=self.events)
41+
42+
request_headers = {'content-type': 'application/json', 'x-api-key': self.api_key}
43+
mock_request_post.assert_called_once_with(url=self.api_host + "/v3/events",
44+
headers=request_headers,
45+
data=json.dumps(self.events),
46+
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)
47+
48+
def test_send_odp_ovents_success(self):
49+
with mock.patch('requests.post') as mock_request_post:
50+
# no need to mock url and content because we're not returning the response
51+
mock_request_post.return_value = self.fake_server_response(status_code=200)
52+
53+
api = ZaiusRestApiManager()
54+
should_retry = api.send_odp_events(api_key=self.api_key,
55+
api_host=self.api_host,
56+
events=self.events) # content of events doesn't matter for the test
57+
58+
self.assertFalse(should_retry)
59+
60+
def test_send_odp_events_invalid_json_no_retry(self):
61+
events = {1, 2, 3} # using a set to trigger JSON-not-serializable error
62+
63+
with mock.patch('requests.post') as mock_request_post, \
64+
mock.patch('optimizely.logger') as mock_logger:
65+
api = ZaiusRestApiManager(logger=mock_logger)
66+
should_retry = api.send_odp_events(api_key=self.api_key,
67+
api_host=self.api_host,
68+
events=events)
69+
70+
self.assertFalse(should_retry)
71+
mock_request_post.assert_not_called()
72+
mock_logger.error.assert_called_once_with(
73+
'ODP event send failed (Object of type set is not JSON serializable).')
74+
75+
def test_send_odp_events_invalid_url_no_retry(self):
76+
invalid_url = 'https://*api.zaius.com'
77+
78+
with mock.patch('requests.post',
79+
side_effect=request_exception.InvalidURL('Invalid URL')) as mock_request_post, \
80+
mock.patch('optimizely.logger') as mock_logger:
81+
api = ZaiusRestApiManager(logger=mock_logger)
82+
should_retry = api.send_odp_events(api_key=self.api_key,
83+
api_host=invalid_url,
84+
events=self.events)
85+
86+
self.assertFalse(should_retry)
87+
mock_request_post.assert_called_once()
88+
mock_logger.error.assert_called_once_with('ODP event send failed (Invalid URL).')
89+
90+
def test_send_odp_events_network_error_retry(self):
91+
with mock.patch('requests.post',
92+
side_effect=request_exception.ConnectionError('Connection error')) as mock_request_post, \
93+
mock.patch('optimizely.logger') as mock_logger:
94+
api = ZaiusRestApiManager(logger=mock_logger)
95+
should_retry = api.send_odp_events(api_key=self.api_key,
96+
api_host=self.api_host,
97+
events=self.events)
98+
99+
self.assertTrue(should_retry)
100+
mock_request_post.assert_called_once()
101+
mock_logger.error.assert_called_once_with('ODP event send failed (network error).')
102+
103+
def test_send_odp_events_400_no_retry(self):
104+
with mock.patch('requests.post') as mock_request_post, \
105+
mock.patch('optimizely.logger') as mock_logger:
106+
mock_request_post.return_value = self.fake_server_response(status_code=400,
107+
url=self.api_host,
108+
content=self.failure_response_data)
109+
110+
api = ZaiusRestApiManager(logger=mock_logger)
111+
should_retry = api.send_odp_events(api_key=self.api_key,
112+
api_host=self.api_host,
113+
events=self.events)
114+
115+
self.assertFalse(should_retry)
116+
mock_request_post.assert_called_once()
117+
mock_logger.error.assert_called_once_with('ODP event send failed ({"title":"Bad Request","status":400,'
118+
'"timestamp":"2022-07-01T20:44:00.945Z","detail":{"invalids":'
119+
'[{"event":0,"message":"missing \'type\' field"}]}}).')
120+
121+
def test_send_odp_events_500_retry(self):
122+
with mock.patch('requests.post') as mock_request_post, \
123+
mock.patch('optimizely.logger') as mock_logger:
124+
mock_request_post.return_value = self.fake_server_response(status_code=500, url=self.api_host)
125+
126+
api = ZaiusRestApiManager(logger=mock_logger)
127+
should_retry = api.send_odp_events(api_key=self.api_key,
128+
api_host=self.api_host,
129+
events=self.events)
130+
131+
self.assertTrue(should_retry)
132+
mock_request_post.assert_called_once()
133+
mock_logger.error.assert_called_once_with('ODP event send failed (500 Server Error: None for url: test-host).')
134+
135+
# test json responses
136+
success_response_data = '{"title":"Accepted","status":202,"timestamp":"2022-07-01T16:04:06.786Z"}'
137+
138+
failure_response_data = '{"title":"Bad Request","status":400,"timestamp":"2022-07-01T20:44:00.945Z",' \
139+
'"detail":{"invalids":[{"event":0,"message":"missing \'type\' field"}]}}'

0 commit comments

Comments
 (0)