Skip to content

Commit 0da2b38

Browse files
committed
improve fitbit request error handling, other cleanup
- Register compliance hooks so we stop erroneously getting MissingTokenError - Require redirect_uri - Use request-oauthlib auto refresh mechanism, using 'expires_at' - Let request-oauthlib do more of the work in general - Reconfigure some tests to engage the request-oauthlib code
1 parent 9f8b2cf commit 0da2b38

7 files changed

Lines changed: 217 additions & 147 deletions

File tree

fitbit/api.py

Lines changed: 55 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
1515

1616
from . import exceptions
17+
from .compliance import fitbit_compliance_fix
1718
from .utils import curry
1819

1920

@@ -27,9 +28,9 @@ class FitbitOauth2Client(object):
2728
access_token_url = request_token_url
2829
refresh_token_url = request_token_url
2930

30-
def __init__(self, client_id, client_secret,
31-
access_token=None, refresh_token=None, refresh_cb=None,
32-
*args, **kwargs):
31+
def __init__(self, client_id, client_secret, access_token=None,
32+
refresh_token=None, expires_at=None, refresh_cb=None, *args,
33+
**kwargs):
3334
"""
3435
Create a FitbitOauth2Client object. Specify the first 7 parameters if
3536
you have them to access user data. Specify just the first 2 parameters
@@ -39,15 +40,21 @@ def __init__(self, client_id, client_secret,
3940
- access_token, refresh_token are obtained after the user grants permission
4041
"""
4142

42-
self.session = requests.Session()
43-
self.client_id = client_id
44-
self.client_secret = client_secret
45-
self.token = {
46-
'access_token': access_token,
47-
'refresh_token': refresh_token
48-
}
49-
self.refresh_cb = refresh_cb
50-
self.oauth = OAuth2Session(client_id)
43+
self.client_id, self.client_secret = client_id, client_secret
44+
token = {}
45+
if access_token and refresh_token:
46+
token.update({
47+
'access_token': access_token,
48+
'refresh_token': refresh_token
49+
})
50+
if expires_at:
51+
token['expires_at'] = expires_at
52+
self.session = fitbit_compliance_fix(OAuth2Session(
53+
client_id,
54+
auto_refresh_url=self.refresh_token_url,
55+
token_updater=refresh_cb,
56+
token=token,
57+
))
5158
self.timeout = kwargs.get("timeout", None)
5259

5360
def _request(self, method, url, **kwargs):
@@ -58,7 +65,17 @@ def _request(self, method, url, **kwargs):
5865
kwargs['timeout'] = self.timeout
5966

6067
try:
61-
return self.session.request(method, url, **kwargs)
68+
response = self.session.request(method, url, **kwargs)
69+
70+
# If our current token has no expires_at, or something manages to slip
71+
# through that check
72+
if response.status_code == 401:
73+
d = json.loads(response.content.decode('utf8'))
74+
if d['errors'][0]['errorType'] == 'expired_token':
75+
self.refresh_token()
76+
response = self.session.request(method, url, **kwargs)
77+
78+
return response
6279
except requests.Timeout as e:
6380
raise exceptions.Timeout(*e.args)
6481

@@ -68,97 +85,68 @@ def make_request(self, url, data={}, method=None, **kwargs):
6885
6986
https://dev.fitbit.com/docs/oauth2/#authorization-errors
7087
"""
71-
if not method:
72-
method = 'POST' if data else 'GET'
73-
74-
try:
75-
auth = OAuth2(client_id=self.client_id, token=self.token)
76-
response = self._request(method, url, data=data, auth=auth, **kwargs)
77-
except (exceptions.HTTPUnauthorized, TokenExpiredError) as e:
78-
self.refresh_token()
79-
auth = OAuth2(client_id=self.client_id, token=self.token)
80-
response = self._request(method, url, data=data, auth=auth, **kwargs)
81-
82-
# yet another token expiration check
83-
# (the above try/except only applies if the expired token was obtained
84-
# using the current instance of the class this is a a general case)
85-
if response.status_code == 401:
86-
d = json.loads(response.content.decode('utf8'))
87-
try:
88-
if(d['errors'][0]['errorType'] == 'expired_token' and
89-
d['errors'][0]['message'].find('Access token expired:') == 0):
90-
self.refresh_token()
91-
auth = OAuth2(client_id=self.client_id, token=self.token)
92-
response = self._request(method, url, data=data, auth=auth, **kwargs)
93-
except:
94-
pass
88+
method = method or ('POST' if data else 'GET')
89+
response = self._request(method, url, data=data, **kwargs)
9590

9691
exceptions.detect_and_raise_error(response)
9792

9893
return response
9994

100-
def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs):
95+
def authorize_token_url(self, redirect_uri, scope=None, **kwargs):
10196
"""Step 1: Return the URL the user needs to go to in order to grant us
10297
authorization to look at their data. Then redirect the user to that
10398
URL, open their browser to it, or tell them to copy the URL into their
10499
browser.
105100
- scope: pemissions that that are being requested [default ask all]
106-
- redirect_uri: url to which the reponse will posted
107-
required only if your app does not have one
101+
- redirect_uri: url to which the reponse will posted. required
108102
for more info see https://dev.fitbit.com/docs/oauth2/
109103
"""
110104

111-
# the scope parameter is caussing some issues when refreshing tokens
112-
# so not saving it
113-
old_scope = self.oauth.scope
114-
old_redirect = self.oauth.redirect_uri
115-
if scope:
116-
self.oauth.scope = scope
117-
else:
118-
self.oauth.scope = [
119-
"activity", "nutrition", "heartrate", "location", "nutrition",
120-
"profile", "settings", "sleep", "social", "weight"
121-
]
122-
123-
if redirect_uri:
124-
self.oauth.redirect_uri = redirect_uri
105+
self.session.scope = scope or [
106+
"activity",
107+
"nutrition",
108+
"heartrate",
109+
"location",
110+
"nutrition",
111+
"profile",
112+
"settings",
113+
"sleep",
114+
"social",
115+
"weight",
116+
]
117+
self.session.redirect_uri = redirect_uri
125118

126-
out = self.oauth.authorization_url(self.authorization_url, **kwargs)
127-
self.oauth.scope = old_scope
128-
self.oauth.redirect_uri = old_redirect
129-
return(out)
119+
return self.session.authorization_url(self.authorization_url, **kwargs)
130120

131-
def fetch_access_token(self, code, redirect_uri):
121+
def fetch_access_token(self, code):
132122

133123
"""Step 2: Given the code from fitbit from step 1, call
134124
fitbit again and returns an access token object. Extract the needed
135125
information from that and save it to use in future API calls.
136126
the token is internally saved
137127
"""
138-
auth = OAuth2Session(self.client_id, redirect_uri=redirect_uri)
139-
self.token = auth.fetch_token(
128+
self.session.fetch_token(
140129
self.access_token_url,
141130
username=self.client_id,
142131
password=self.client_secret,
143132
code=code)
144133

145-
return self.token
134+
return self.session.token
146135

147136
def refresh_token(self):
148137
"""Step 3: obtains a new access_token from the the refresh token
149138
obtained in step 2.
150139
the token is internally saved
151140
"""
152-
self.token = self.oauth.refresh_token(
141+
self.session.refresh_token(
153142
self.refresh_token_url,
154-
refresh_token=self.token['refresh_token'],
155143
auth=HTTPBasicAuth(self.client_id, self.client_secret)
156144
)
157145

158-
if self.refresh_cb:
159-
self.refresh_cb(self.token)
146+
if self.session.token_updater:
147+
self.session.token_updater(self.session.token)
160148

161-
return self.token
149+
return self.session.token
162150

163151

164152
class Fitbit(object):

fitbit/compliance.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""
2+
The Fitbit API breaks from the OAuth2 RFC standard by returning an "errors"
3+
object list, rather than a single "error" string. This puts hooks in place so
4+
that oauthlib can process an error in the results from access token and refresh
5+
token responses. This is necessary to prevent getting the generic red herring
6+
MissingTokenError.
7+
"""
8+
9+
from json import loads, dumps
10+
11+
from oauthlib.common import to_unicode
12+
13+
14+
def fitbit_compliance_fix(session):
15+
16+
def _missing_error(r):
17+
token = loads(r.text)
18+
if 'errors' in token:
19+
# Set the error to the first one we have
20+
token['error'] = token['errors'][0]['errorType']
21+
r._content = to_unicode(dumps(token)).encode('UTF-8')
22+
return r
23+
24+
session.register_compliance_hook('access_token_response', _missing_error)
25+
session.register_compliance_hook('refresh_token_response', _missing_error)
26+
return session

0 commit comments

Comments
 (0)