Skip to content

Commit 893d173

Browse files
authored
(feat) add zaius graphql api manager w tests (#396)
* (feat) add zaius graphql api manager w tests * fix linting error in py 3.9 line too long * remove refactor extract_component(), use simpler dict query * address PR comments, fix excepton handling, add tests * optimized tests, exceptions and enums * refactor fake_server_response function * add fake_srver_response to 400 error test
1 parent aee87a5 commit 893d173

File tree

6 files changed

+703
-16
lines changed

6 files changed

+703
-16
lines changed

optimizely/event_dispatcher.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,29 @@
1313

1414
import json
1515
import logging
16-
import requests
16+
from sys import version_info
1717

18+
import requests
1819
from requests import exceptions as request_exception
19-
from sys import version_info
2020

21-
from .helpers import enums
2221
from . import event_builder
22+
from .helpers.enums import HTTPVerbs, EventDispatchConfig
2323

2424
if version_info < (3, 8):
25-
from typing_extensions import Protocol, Final
25+
from typing_extensions import Protocol
2626
else:
27-
from typing import Protocol, Final # type: ignore
28-
29-
30-
REQUEST_TIMEOUT: Final = 10
27+
from typing import Protocol # type: ignore
3128

3229

3330
class CustomEventDispatcher(Protocol):
3431
"""Interface for a custom event dispatcher and required method `dispatch_event`. """
32+
3533
def dispatch_event(self, event: event_builder.Event) -> None:
3634
...
3735

3836

3937
class EventDispatcher:
38+
4039
@staticmethod
4140
def dispatch_event(event: event_builder.Event) -> None:
4241
""" Dispatch the event being represented by the Event object.
@@ -45,11 +44,13 @@ def dispatch_event(event: event_builder.Event) -> None:
4544
event: Object holding information about the request to be dispatched to the Optimizely backend.
4645
"""
4746
try:
48-
if event.http_verb == enums.HTTPVerbs.GET:
49-
requests.get(event.url, params=event.params, timeout=REQUEST_TIMEOUT).raise_for_status()
50-
elif event.http_verb == enums.HTTPVerbs.POST:
47+
if event.http_verb == HTTPVerbs.GET:
48+
requests.get(event.url, params=event.params,
49+
timeout=EventDispatchConfig.REQUEST_TIMEOUT).raise_for_status()
50+
elif event.http_verb == HTTPVerbs.POST:
5151
requests.post(
52-
event.url, data=json.dumps(event.params), headers=event.headers, timeout=REQUEST_TIMEOUT,
52+
event.url, data=json.dumps(event.params), headers=event.headers,
53+
timeout=EventDispatchConfig.REQUEST_TIMEOUT,
5354
).raise_for_status()
5455

5556
except request_exception.RequestException as error:

optimizely/helpers/enums.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ class Errors:
120120
NONE_VARIABLE_KEY_PARAMETER: Final = '"None" is an invalid value for variable key.'
121121
UNSUPPORTED_DATAFILE_VERSION: Final = (
122122
'This version of the Python SDK does not support the given datafile version: "{}".')
123+
INVALID_SEGMENT_IDENTIFIER = 'Audience segments fetch failed (invalid identifier).'
124+
FETCH_SEGMENTS_FAILED = 'Audience segments fetch failed ({}).'
125+
ODP_EVENT_FAILED = 'ODP event send failed (invalid url).'
126+
ODP_NOT_ENABLED = 'ODP is not enabled. '
123127

124128

125129
class ForcedDecisionLogs:
@@ -186,3 +190,18 @@ class NotificationTypes:
186190
class VersionType:
187191
IS_PRE_RELEASE: Final = '-'
188192
IS_BUILD: Final = '+'
193+
194+
195+
class EventDispatchConfig:
196+
"""Event dispatching configs."""
197+
REQUEST_TIMEOUT: Final = 10
198+
199+
200+
class OdpRestApiConfig:
201+
"""ODP Rest API configs."""
202+
REQUEST_TIMEOUT: Final = 10
203+
204+
205+
class OdpGraphQLApiConfig:
206+
"""ODP GraphQL API configs."""
207+
REQUEST_TIMEOUT: Final = 10
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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, JSONDecodeError
21+
22+
from optimizely import logger as optimizely_logger
23+
from optimizely.helpers.enums import Errors, OdpGraphQLApiConfig
24+
25+
"""
26+
ODP GraphQL API
27+
- https://api.zaius.com/v3/graphql
28+
- test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ"
29+
30+
31+
[GraphQL Request]
32+
33+
# fetch info with fs_user_id for ["has_email", "has_email_opted_in", "push_on_sale"] segments
34+
curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d
35+
'{"query":"query {customer(fs_user_id: \"tester-101\") {audiences(subset:[\"has_email\",
36+
\"has_email_opted_in\", \"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql
37+
# fetch info with vuid for ["has_email", "has_email_opted_in", "push_on_sale"] segments
38+
curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d
39+
'{"query":"query {customer(vuid: \"d66a9d81923d4d2f99d8f64338976322\") {audiences(subset:[\"has_email\",
40+
\"has_email_opted_in\", \"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql
41+
42+
query MyQuery {
43+
customer(vuid: "d66a9d81923d4d2f99d8f64338976322") {
44+
audiences(subset:["has_email", "has_email_opted_in", "push_on_sale"]) {
45+
edges {
46+
node {
47+
name
48+
state
49+
}
50+
}
51+
}
52+
}
53+
}
54+
55+
56+
[GraphQL Response]
57+
{
58+
"data": {
59+
"customer": {
60+
"audiences": {
61+
"edges": [
62+
{
63+
"node": {
64+
"name": "has_email",
65+
"state": "qualified",
66+
}
67+
},
68+
{
69+
"node": {
70+
"name": "has_email_opted_in",
71+
"state": "qualified",
72+
}
73+
},
74+
...
75+
]
76+
}
77+
}
78+
}
79+
}
80+
81+
[GraphQL Error Response]
82+
{
83+
"errors": [
84+
{
85+
"message": "Exception while fetching data (/customer) : java.lang.RuntimeException:
86+
could not resolve _fs_user_id = asdsdaddddd",
87+
"locations": [
88+
{
89+
"line": 2,
90+
"column": 3
91+
}
92+
],
93+
"path": [
94+
"customer"
95+
],
96+
"extensions": {
97+
"classification": "InvalidIdentifierException"
98+
}
99+
}
100+
],
101+
"data": {
102+
"customer": null
103+
}
104+
}
105+
"""
106+
107+
108+
class ZaiusGraphQLApiManager:
109+
"""Interface for manging the fetching of audience segments."""
110+
111+
def __init__(self, logger: Optional[optimizely_logger.Logger] = None):
112+
self.logger = logger or optimizely_logger.NoOpLogger()
113+
114+
def fetch_segments(self, api_key: str, api_host: str, user_key: str,
115+
user_value: str, segments_to_check: list[str]) -> Optional[list[str]]:
116+
"""
117+
Fetch segments from ODP GraphQL API.
118+
119+
Args:
120+
api_key: public api key
121+
api_host: domain url of the host
122+
user_key: vuid or fs_user_id (client device id or fullstack id)
123+
user_value: vaue of user_key
124+
segments_to_check: lit of segments to check
125+
126+
Returns:
127+
Audience segments from GraphQL.
128+
"""
129+
url = f'{api_host}/v3/graphql'
130+
request_headers = {'content-type': 'application/json',
131+
'x-api-key': str(api_key)}
132+
133+
segments_filter = self.make_subset_filter(segments_to_check)
134+
payload_dict = {
135+
'query': 'query {customer(' + str(user_key) + ': "' + str(user_value) + '") '
136+
'{audiences' + segments_filter + ' {edges {node {name state}}}}}'
137+
}
138+
139+
try:
140+
response = requests.post(url=url,
141+
headers=request_headers,
142+
data=json.dumps(payload_dict),
143+
timeout=OdpGraphQLApiConfig.REQUEST_TIMEOUT)
144+
145+
response.raise_for_status()
146+
response_dict = response.json()
147+
148+
# There is no status code with network issues such as ConnectionError or Timeouts
149+
# (i.e. no internet, server can't be reached).
150+
except (ConnectionError, Timeout) as err:
151+
self.logger.debug(f'GraphQL download failed: {err}')
152+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('network error'))
153+
return None
154+
except JSONDecodeError:
155+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('JSON decode error'))
156+
return None
157+
except RequestException as err:
158+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format(err))
159+
return None
160+
161+
if response_dict and 'errors' in response_dict:
162+
try:
163+
error_class = response_dict['errors'][0]['extensions']['classification']
164+
except (KeyError, IndexError):
165+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('decode error'))
166+
return None
167+
168+
if error_class == 'InvalidIdentifierException':
169+
self.logger.error(Errors.INVALID_SEGMENT_IDENTIFIER)
170+
return None
171+
else:
172+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format(error_class))
173+
return None
174+
else:
175+
try:
176+
audiences = response_dict['data']['customer']['audiences']['edges']
177+
segments = [edge['node']['name'] for edge in audiences if edge['node']['state'] == 'qualified']
178+
return segments
179+
except KeyError:
180+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('decode error'))
181+
return None
182+
183+
@staticmethod
184+
def make_subset_filter(segments: list[str]) -> str:
185+
"""
186+
segments = []: (fetch none)
187+
--> subsetFilter = "(subset:[])"
188+
segments = ["a"]: (fetch one segment)
189+
--> subsetFilter = '(subset:["a"])'
190+
191+
Purposely using .join() method to deal with special cases of
192+
any words with apostrophes (i.e. don't). .join() method enquotes
193+
correctly without conflicting with the apostrophe.
194+
"""
195+
if segments == []:
196+
return '(subset:[])'
197+
return '(subset:["' + '", "'.join(segments) + '"]' + ')'

optimizely/optimizely.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ def _get_feature_variable_for_type(
255255
self, project_config: project_config.ProjectConfig, feature_key: str, variable_key: str,
256256
variable_type: Optional[str], user_id: str, attributes: Optional[UserAttributes]
257257
) -> Any:
258-
""" Helper method to determine value for a certain variable attached to a feature flag based on type of variable.
258+
""" Helper method to determine value for a certain variable attached to a feature flag based on
259+
type of variable.
259260
260261
Args:
261262
project_config: Instance of ProjectConfig.

tests/test_event_dispatcher.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from optimizely import event_builder
2020
from optimizely import event_dispatcher
21+
from optimizely.helpers.enums import EventDispatchConfig
2122

2223

2324
class EventDispatcherTest(unittest.TestCase):
@@ -31,7 +32,7 @@ def test_dispatch_event__get_request(self):
3132
with mock.patch('requests.get') as mock_request_get:
3233
event_dispatcher.EventDispatcher.dispatch_event(event)
3334

34-
mock_request_get.assert_called_once_with(url, params=params, timeout=event_dispatcher.REQUEST_TIMEOUT)
35+
mock_request_get.assert_called_once_with(url, params=params, timeout=EventDispatchConfig.REQUEST_TIMEOUT)
3536

3637
def test_dispatch_event__post_request(self):
3738
""" Test that dispatch event fires off requests call with provided URL, params, HTTP verb and headers. """
@@ -52,7 +53,7 @@ def test_dispatch_event__post_request(self):
5253
url,
5354
data=json.dumps(params),
5455
headers={'Content-Type': 'application/json'},
55-
timeout=event_dispatcher.REQUEST_TIMEOUT,
56+
timeout=EventDispatchConfig.REQUEST_TIMEOUT,
5657
)
5758

5859
def test_dispatch_event__handle_request_exception(self):
@@ -76,6 +77,6 @@ def test_dispatch_event__handle_request_exception(self):
7677
url,
7778
data=json.dumps(params),
7879
headers={'Content-Type': 'application/json'},
79-
timeout=event_dispatcher.REQUEST_TIMEOUT,
80+
timeout=EventDispatchConfig.REQUEST_TIMEOUT,
8081
)
8182
mock_log_error.assert_called_once_with('Dispatch event failed. Error: Failed Request')

0 commit comments

Comments
 (0)