Skip to content

Commit 8f2423d

Browse files
committed
Add status checking (fixes #3). Add settable timeouts on all calls. Move to a4.
* The status API endpoint is deduced from the usermanagement endpoint. * The default timeout is 60 seconds. * Added a live testing suite keyed on availability of credentials and configuration parameters in a "local" directory.
1 parent 125128d commit 8f2423d

File tree

7 files changed

+243
-19
lines changed

7 files changed

+243
-19
lines changed

setup.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@
2020

2121
from setuptools import setup, find_packages
2222

23-
2423
setup(name='umapi-client',
25-
version='2.0a2',
24+
version='2.0a4',
2625
description='Client for the User Management API (UMAPI) from Adobe - see https://adobe.ly/2h1pHgV',
2726
long_description=('The User Management API (aka the UMAPI) is an Adobe-hosted network service '
2827
'which provides Adobe Enterprise customers the ability to manage their users. This '
@@ -43,16 +42,17 @@
4342
license='MIT',
4443
packages=find_packages(),
4544
install_requires=[
46-
'requests>=2.4.2',
47-
'cryptography',
48-
'PyJWT',
49-
'six',
45+
'requests>=2.4.2',
46+
'cryptography',
47+
'PyJWT',
48+
'six',
5049
],
5150
setup_requires=[
52-
'pytest-runner',
51+
'pytest-runner',
5352
],
5453
tests_require=[
55-
'pytest',
56-
'mock',
54+
'pytest',
55+
'mock',
56+
'PyYAML',
5757
],
5858
zip_safe=False)

tests/test_connections.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,31 @@
2323

2424
import mock
2525
import pytest
26+
import requests
2627

2728
from conftest import mock_connection_params, MockResponse
28-
from umapi_client import Connection, UnavailableError, ServerError, RequestError
29+
from umapi_client import Connection, UnavailableError, ServerError, RequestError, ClientError
30+
31+
32+
def test_status_success():
33+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
34+
mock_get.return_value = MockResponse(200, body={"build": "2559", "version": "2.1.54", "state":"LIVE"})
35+
conn = Connection(**mock_connection_params)
36+
assert conn.status() == {"build": "2559", "version": "2.1.54", "state":"LIVE"}
37+
38+
39+
def test_status_failure():
40+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
41+
mock_get.return_value = MockResponse(404, text="404 Not Found")
42+
conn = Connection(**mock_connection_params)
43+
pytest.raises (ClientError, conn.status)
44+
45+
46+
def test_status_timeout():
47+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
48+
mock_get.side_effect = requests.Timeout
49+
conn = Connection(**mock_connection_params)
50+
pytest.raises(UnavailableError, conn.status)
2951

3052

3153
def test_get_success():
@@ -42,6 +64,20 @@ def test_post_success():
4264
assert conn.make_call("", [3, 5]).json() == ["test", "body"]
4365

4466

67+
def test_get_timeout():
68+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
69+
mock_get.side_effect = requests.Timeout
70+
conn = Connection(**mock_connection_params)
71+
pytest.raises(UnavailableError, conn.make_call, "")
72+
73+
74+
def test_post_timeout():
75+
with mock.patch("umapi_client.connection.requests.post") as mock_post:
76+
mock_post.side_effect = requests.Timeout
77+
conn = Connection(**mock_connection_params)
78+
pytest.raises(UnavailableError, conn.make_call, "", [3, 5])
79+
80+
4581
def test_get_retry_header_1():
4682
with mock.patch("umapi_client.connection.requests.get") as mock_get:
4783
mock_get.return_value = MockResponse(429, headers={"Retry-After": "1"})

tests/test_live.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright (c) 2016 Adobe Systems Incorporated. All rights reserved.
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
21+
import logging
22+
import re
23+
import os
24+
25+
import pytest
26+
import yaml
27+
import six
28+
29+
import umapi_client
30+
31+
# this test relies on a sensitive configuraition
32+
config_file_name = "local/live_configuration.yaml"
33+
pytestmark = pytest.mark.skipif(not os.access(config_file_name, os.R_OK),
34+
reason="Live config file '{}' not found.".format(config_file_name))
35+
36+
logging.basicConfig(level=logging.INFO,
37+
format='%(asctime)s: %(levelname)s: %(message)s',
38+
datefmt='%m/%d/%Y %I:%M:%S %p')
39+
40+
41+
@pytest.fixture(scope="module")
42+
def config():
43+
with open(config_file_name, "r") as f:
44+
config = yaml.load(f)
45+
creds = config["test_org"]
46+
conn = umapi_client.Connection(org_id=creds["org_id"], auth_dict=creds)
47+
return conn, config
48+
49+
50+
def test_status(config):
51+
conn, _ = config
52+
status = conn.status()
53+
assert status["state"] == "LIVE"
54+
55+
56+
def test_list_users(config):
57+
conn, _ = config
58+
users = umapi_client.UsersQuery(connection=conn, in_domain="")
59+
for user in users:
60+
email = user.get("email", "")
61+
if re.match(r".*@adobe.com$", str(email).lower()):
62+
assert str(user["type"]) == "adobeID"
63+
64+
65+
def test_get_user(config):
66+
conn, params = config
67+
user_query = umapi_client.UserQuery(conn, params["test_user"]["email"])
68+
user = user_query.result()
69+
for k, v in six.iteritems(params["test_user"]):
70+
assert user[k].lower() == v.lower()

tests/test_queries.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import mock
2424

2525
from conftest import mock_connection_params, MockResponse
26-
from umapi_client import Connection, QueryMultiple, ClientError
26+
from umapi_client import Connection, QueryMultiple, QuerySingle, ClientError
2727

2828

2929
def test_query_single_success():
@@ -68,6 +68,13 @@ def test_query_multiple_empty():
6868
assert conn.query_multiple("object") == ([], True)
6969

7070

71+
def test_query_multiple_not_found():
72+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
73+
mock_get.return_value = MockResponse(404, text="404 Object not found")
74+
conn = Connection(**mock_connection_params)
75+
assert conn.query_multiple("object") == ([], True)
76+
77+
7178
def test_query_multiple_paged():
7279
with mock.patch("umapi_client.connection.requests.get") as mock_get:
7380
mock_get.side_effect = [MockResponse(200, {"result": "success",
@@ -87,6 +94,38 @@ def test_query_multiple_paged():
8794
True)
8895

8996

97+
def test_qs_success():
98+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
99+
mock_get.return_value = MockResponse(200, {"result": "success",
100+
"object": {"user": "[email protected]", "type": "adobeID"}})
101+
conn = Connection(**mock_connection_params)
102+
qs = QuerySingle(conn, "object", ["[email protected]"])
103+
assert qs.result() == {"user": "[email protected]", "type": "adobeID"}
104+
105+
106+
def test_qs_not_found():
107+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
108+
mock_get.return_value = MockResponse(404, text="404 Object not found")
109+
conn = Connection(**mock_connection_params)
110+
qs = QuerySingle(conn, "object", ["[email protected]"])
111+
assert qs.result() == {}
112+
113+
114+
def test_qs_reload():
115+
with mock.patch("umapi_client.connection.requests.get") as mock_get:
116+
mock_get.side_effect = [MockResponse(200, {"result": "success",
117+
"object": {"user": "[email protected]", "type": "adobeID"}}),
118+
MockResponse(200, {"result": "success",
119+
"object": {"user": "[email protected]", "type": "adobeID"}})]
120+
conn = Connection(**mock_connection_params)
121+
qs = QuerySingle(conn, "object", ["[email protected]"])
122+
assert qs.result() == {"user": "[email protected]", "type": "adobeID"}
123+
# second verse, same as the first
124+
assert qs.result() == {"user": "[email protected]", "type": "adobeID"}
125+
qs.reload()
126+
assert qs.result() == {"user": "[email protected]", "type": "adobeID"}
127+
128+
90129
def test_qm_iterate_complete():
91130
with mock.patch("umapi_client.connection.requests.get") as mock_get:
92131
mock_get.side_effect = [MockResponse(200, {"result": "success",

umapi_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919
# SOFTWARE.
2020

2121
from .connection import Connection
22-
from .api import Action, UserAction, QueryMultiple
22+
from .api import Action, UserAction, QueryMultiple, UsersQuery, GroupsQuery, QuerySingle, UserQuery
2323
from .error import ClientError, RequestError, ServerError, UnavailableError

umapi_client/api.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ class QueryMultiple:
142142
"""
143143
A QueryMultiple runs a query against a connection. The results can be iterated or fetched in bulk.
144144
"""
145-
146145
def __init__(self, connection, object_type, url_params=None, query_params=None):
147146
# type: (Connection, str, list, dict) -> None
148147
"""
@@ -234,16 +233,18 @@ class UsersQuery(QueryMultiple):
234233
Query for users meeting (optional) criteria
235234
"""
236235

237-
def __init__(self, connection, in_group="", in_domain=""):
236+
def __init__(self, connection, in_group="", in_domain="", identity_type=""):
238237
"""
239238
Create a query for all users, or for those in a group or domain or both
240239
:param connection: Connection to run the query against
241240
:param in_group: (optional) name of the group to restrict the query to
242241
:param in_domain: (optional) name of the domain to restrict the query to
243242
"""
244243
groups = [in_group] if in_group else []
245-
domains = {"domain": in_domain} if in_domain else {}
246-
QueryMultiple.__init__(self, connection=connection, object_type="user", url_params=groups, query_params=domains)
244+
params = {}
245+
if in_domain: params["domain"] = str(in_domain)
246+
if identity_type: params["type"] = str(identity_type)
247+
QueryMultiple.__init__(self, connection=connection, object_type="user", url_params=groups, query_params=params)
247248

248249

249250
class GroupsQuery(QueryMultiple):
@@ -257,3 +258,59 @@ def __init__(self, connection):
257258
:param connection: Connection to run the query against
258259
"""
259260
QueryMultiple.__init__(self, connection=connection, object_type="group")
261+
262+
class QuerySingle:
263+
"""
264+
Look for a single object
265+
"""
266+
def __init__(self, connection, object_type, url_params=None, query_params=None):
267+
# type: (Connection, str, list, dict) -> None
268+
"""
269+
Provide the connection and query parameters when you create the query.
270+
271+
:param connection: The Connection to run the query against
272+
:param object_type: The type of object being queried (e.g., "user" or "group")
273+
:param url_params: Query qualifiers that go in the URL path (e.g., a group name when querying users)
274+
:param query_params: Query qualifiers that go in the query string (e.g., a domain name)
275+
"""
276+
self.conn = connection
277+
self.object_type = object_type
278+
self.url_params = url_params if url_params else []
279+
self.query_params = query_params if query_params else {}
280+
self._result = None
281+
282+
def reload(self):
283+
"""
284+
Rerun the query (lazily).
285+
The result will contain a value on the server side that have changed since the last run.
286+
:return: None
287+
"""
288+
self._result = None
289+
290+
def _fetch_result(self):
291+
"""
292+
Fetch the queried object.
293+
"""
294+
self._result = self.conn.query_single(self.object_type, self.url_params, self.query_params)
295+
296+
def result(self):
297+
"""
298+
Fetch the result, if we haven't already or if reload has been called.
299+
:return: the result object of the query.
300+
"""
301+
if self._result is None:
302+
self._fetch_result()
303+
return self._result
304+
305+
class UserQuery(QuerySingle):
306+
"""
307+
Query for a single user
308+
"""
309+
310+
def __init__(self, connection, email):
311+
"""
312+
Create a query for the user with the given email
313+
:param connection: Connection to run the query against
314+
:param email: email of user to query for
315+
"""
316+
QuerySingle.__init__(self, connection=connection, object_type="user", url_params=[str(email)])

umapi_client/connection.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(self,
4949
retry_max_attempts=4,
5050
retry_first_delay=15,
5151
retry_random_delay=5,
52+
timeout_seconds=60.0,
5253
**kwargs):
5354
"""
5455
Open a connection for the given parameters that has the given options.
@@ -87,6 +88,7 @@ def __init__(self,
8788
self.retry_max_attempts = retry_max_attempts
8889
self.retry_first_delay = retry_first_delay
8990
self.retry_random_delay = retry_random_delay
91+
self.timeout = float(timeout_seconds) if float(timeout_seconds) > 0.0 else 60.0
9092
if auth:
9193
self.auth = auth
9294
elif auth_dict:
@@ -104,6 +106,22 @@ def _get_auth(self, ims_host, ims_endpoint_jwt,
104106
token = AccessRequest("https://" + ims_host + ims_endpoint_jwt, api_key, client_secret, jwt())
105107
return Auth(api_key, token())
106108

109+
def status(self):
110+
"""
111+
Check whether the UMAPI service is up, and return data about version and build.
112+
:return: dictionary of status data, or raise UnavailableError.
113+
"""
114+
components = urlparse.urlparse(self.endpoint)
115+
try:
116+
result = requests.get(components[0] + "://" + components[1] + "/status", timeout=self.timeout)
117+
except Exception as e:
118+
if self.logger: self.logger.error("Failed to connect to server: %s", e)
119+
raise UnavailableError(1, int(self.timeout), None)
120+
if result.status_code == 200:
121+
return result.json()
122+
else:
123+
raise ClientError("Response status was {:d}".format(result.status_code), result)
124+
107125
def query_single(self, object_type, url_params, query_params=None):
108126
# type: (str, list, dict) -> dict
109127
"""
@@ -225,18 +243,22 @@ def make_call(self, path, body=None):
225243
if body:
226244
request_body = json.dumps(body)
227245
def call():
228-
return requests.post(self.endpoint + path, auth=self.auth, data=request_body)
246+
return requests.post(self.endpoint + path, auth=self.auth, data=request_body, timeout=self.timeout)
229247
else:
230248
def call():
231-
return requests.get(self.endpoint + path, auth=self.auth)
249+
return requests.get(self.endpoint + path, auth=self.auth, timeout=self.timeout)
232250

233251
total_time = wait_time = 0
234252
for num_attempts in range(1, self.retry_max_attempts + 1):
235253
if wait_time > 0:
236254
sleep(wait_time)
237255
total_time += wait_time
238256
wait_time = 0
239-
result = call()
257+
try:
258+
result = call()
259+
except requests.Timeout:
260+
total_time += int(self.timeout)
261+
raise UnavailableError(num_attempts, total_time, None)
240262
if result.status_code == 200:
241263
return result
242264
elif result.status_code in [429, 502, 503, 504]:

0 commit comments

Comments
 (0)