Skip to content

Commit d982b15

Browse files
author
Thomas Basche
committed
Refactor API requests into separate class
1 parent 6187f01 commit d982b15

10 files changed

Lines changed: 183 additions & 33 deletions

File tree

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[MESSAGES CONTROL]
22

3-
disable=C0103,R0903,R0913
3+
disable=C0103,R0903,R0913,logging-format-interpolation

reposit/auth/account.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Define an Account for a logged in user.
33
"""
4-
from reposit.data.utils import api_response
4+
from reposit.data.api import ApiRequest
55

66

77
class Account(object):
@@ -21,9 +21,9 @@ def get_user_keys(self):
2121
:param auth_headers:
2222
:return:
2323
"""
24-
return api_response(
25-
url='https://{}/v2/userkeys',
24+
request = ApiRequest(
25+
path='/v2/userkeys',
2626
controller=self,
27-
field='userKeys',
28-
no_user_key=True
27+
schema={'userKeys': {}}
2928
)
29+
return request.get()

reposit/auth/connect.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22
Establish a connection with the auth endpoints
33
"""
44
from __future__ import absolute_import
5-
import os
65

76
import logging
87

98
import requests
109
from requests import HTTPError
11-
from requests.auth import HTTPBasicAuth
1210

13-
AUTH_URL = os.environ.get('AUTH_URL')
14-
ENV = os.environ.get('ENV')
11+
from requests.auth import HTTPBasicAuth
12+
from reposit.settings import AUTH_PATH
1513

1614
logger = logging.getLogger(__name__)
1715

@@ -25,7 +23,7 @@ def _login(self):
2523
Given a username and password, obtain an access token
2624
:return:
2725
"""
28-
resp = requests.post(AUTH_URL, auth=HTTPBasicAuth(self.username, self.password), headers={
26+
resp = requests.post(AUTH_PATH, auth=HTTPBasicAuth(self.username, self.password), headers={
2927
"Reposit-Auth": "API"
3028
})
3129
try:

reposit/data/api.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
Define an API connection object
3+
"""
4+
import logging
5+
6+
import requests
7+
import six
8+
9+
from reposit.data.exceptions import InvalidControllerException
10+
from reposit.data.utils import is_valid_url
11+
from reposit.settings import BASE_URL
12+
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class ApiRequest(object):
18+
"""
19+
A class which represents a request/response from the Reposit API.
20+
21+
Why a class and not a function?
22+
well we can do some comprehensive validation and checking on creation
23+
"""
24+
def __str__(self):
25+
"""
26+
This should give us a good idea (for debugging)
27+
exactly the request and what data we wanted.
28+
We don't want to log the controller object as this
29+
will leak the access token.
30+
:return:
31+
"""
32+
return self.url, self.schema
33+
34+
def __init__(self, path, controller, schema, **kwargs):
35+
"""
36+
:param path: the url endpoint (e.g. /v2/deployments etc. etc.
37+
:param controller: a Controller instance
38+
:param schema: A dict representing the structure of the response.
39+
E.g. we want houseP and the API returns the following:
40+
{
41+
"data": {
42+
"houseP": {'blah'}
43+
}
44+
}
45+
So the schema in this case should be a dict like so:
46+
{
47+
"data": {
48+
"houseP": {}
49+
}
50+
}
51+
This is because some of the API responses vary in structure, so
52+
we can define them on the fly easily :)
53+
54+
:param kwargs: additional arguments when requesting data
55+
"""
56+
if path.startswith('/'):
57+
self.url = '{}{}'.format(BASE_URL, path)
58+
else:
59+
self.url = '{}/{}'.format(BASE_URL, path)
60+
61+
assert is_valid_url(self.url)
62+
63+
if not controller.auth_headers:
64+
raise InvalidControllerException
65+
self.controller = controller
66+
67+
# a lookup of the response schema
68+
self.schema = schema
69+
"""
70+
Various optional args here
71+
"""
72+
# if the response a list?
73+
self.is_list = kwargs.get('format_list', False)
74+
75+
def get(self):
76+
"""
77+
Once a connection is defined as valid, then a request can be
78+
made. This formats the response as well as checks the response
79+
returns an OK http code
80+
:return:
81+
"""
82+
resp = requests.get(
83+
self.url,
84+
headers=self.controller.auth_headers
85+
)
86+
try:
87+
resp.raise_for_status()
88+
except Exception as ex:
89+
# Hijack the exception to log the exact issue.
90+
logger.exception('Error retrieving data: {}'.format(self))
91+
raise ex
92+
93+
data = self._simple_format_for_fields(resp)
94+
return data
95+
96+
def _simple_format_for_fields(self, api_response):
97+
"""
98+
Based on the schema provided, format the data accordingly.
99+
100+
:return:
101+
"""
102+
103+
data = api_response.json()
104+
data_for_retrieval = []
105+
schema_items = self.schema.items() if six.PY3 else self.schema.iteritems()
106+
for key, _ in schema_items:
107+
fetched_data = data.get(key)
108+
if fetched_data:
109+
data_for_retrieval.append(fetched_data)
110+
del data[key]
111+
112+
# if its a list of only one thing then simplify it a smidge.
113+
# Most routes tend to only be.
114+
if len(data_for_retrieval) == 1:
115+
return data_for_retrieval[0]
116+
117+
return data_for_retrieval

reposit/data/controller.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
from __future__ import absolute_import
55

6+
from reposit.data.api import ApiRequest
67
from reposit.data.utils import api_response, device_summary
78

89

@@ -25,11 +26,12 @@ def battery_capacity(self):
2526
Return the kwh battery capacity
2627
:return:
2728
"""
28-
return api_response(
29-
url='https://{}/v2/deployments/{}/battery/capacity',
29+
request = ApiRequest(
30+
path='v2/deployments/{}/battery/capacity'.format(self.user_key),
3031
controller=self,
31-
field='batteryCapacity'
32+
schema={'batteryCapacity': {}}
3233
)
34+
return request.get()
3335

3436
@property
3537
def battery_min_state_of_charge(self):

reposit/data/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Custom exceptions relating to the usage of the controller class
3+
"""
4+
5+
6+
class InvalidControllerException(Exception):
7+
"""
8+
A connection is attempted without auth headers
9+
"""
10+
pass

reposit/data/utils.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,28 @@
33
"""
44
from __future__ import absolute_import
55

6+
import re
7+
68
import arrow
79
import requests
810
import six
911

10-
from reposit.auth.connect import ENV
12+
from reposit.settings import BASE_URL
13+
14+
15+
# adapted from Django :)
16+
VALID_URL_REGEX = re.compile(
17+
r'^(?:http|ftp)s?://' # http:// or https://
18+
)
19+
20+
21+
def is_valid_url(url):
22+
"""
23+
Check if a url is valid for an api request.
24+
:param url:
25+
:return:
26+
"""
27+
return bool(re.match(VALID_URL_REGEX, url))
1128

1229

1330
def api_response(url, controller, field, subfield=None, format_list=False, no_user_key=False):
@@ -24,9 +41,10 @@ def api_response(url, controller, field, subfield=None, format_list=False, no_us
2441
subfield_check = True
2542

2643
if no_user_key:
27-
resp = requests.get(url.format(ENV), headers=controller.auth_headers)
44+
resp = requests.get(url.format(BASE_URL), headers=controller.auth_headers)
2845
else:
29-
resp = requests.get(url.format(ENV, controller.user_key), headers=controller.auth_headers)
46+
resp = requests.get(url.format(BASE_URL, controller.user_key),
47+
headers=controller.auth_headers)
3048

3149
resp.raise_for_status()
3250
if not subfield_check and not format_list:
@@ -52,7 +70,7 @@ def device_summary(controller):
5270
:param controller: Controller instance
5371
:return:
5472
"""
55-
url = 'https://{}/v2/deployments/{}/summary/now'.format(ENV, controller.user_key)
73+
url = '{}/v2/deployments/{}/summary/now'.format(BASE_URL, controller.user_key)
5674
resp = requests.get(url, headers=controller.auth_headers)
5775
resp.raise_for_status()
5876
summary = format_summary_response(resp.json())

reposit/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Global variables for the library
3+
"""
4+
import os
5+
BASE_URL = os.environ.get('BASE_URL')
6+
AUTH_PATH = os.environ.get('AUTH_PATH', '{}/v2/auth/login/').format(BASE_URL)

reposit_tests/test_data_formatting.py

Lines changed: 0 additions & 15 deletions
This file was deleted.

reposit_tests/test_is_valid_url.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
3+
from reposit.data.utils import is_valid_url
4+
5+
6+
@pytest.mark.parametrize('url, expected', [
7+
('https://www.repositpower.com', True),
8+
('http://www.repositpower.com', True),
9+
('://www.repositpower.com', False),
10+
('://www.repositpower', False),
11+
('www.repositpower', False),
12+
])
13+
def test_is_valid_url(url, expected):
14+
assert is_valid_url(url) == expected

0 commit comments

Comments
 (0)