Skip to content
This repository was archived by the owner on Oct 2, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,37 @@ if (auth_status.pending == false and auth_status.granted == true):
#### Handling Errors
If any request runs into an error a `ToopherApiError` will be thrown with more details on what went wrong.

#### Zero-Storage usage option
Requesters can choose to integrate the Toopher API in a way does not require storing any per-user data such as Pairing ID and Terminal ID - all of the storage
is handled by the Toopher API Web Service, allowing your local database to remain unchanged. If the Toopher API needs more data, it will `die()` with a specific
error string that allows your code to respond appropriately.

```python
try:
# optimistically try to authenticate against Toopher API with username and a Terminal Identifier
# Terminal Identifer is typically a randomly generated secure browser cookie. It does not
# need to be human-readable
auth = api.authenticate_by_user_name("[email protected]", "<terminal identifier>")

# if you got here, everything is good! poll the auth request status as described above
# there are four distinct errors ToopherAPI can return if it needs more data
except UserDisabledError:
# you have marked this user as disabled in the Toopher API.
except UnknownUserError:
# This user has not yet paired a mobile device with their account. Pair them
# using api.pair() as described above, then re-try authentication
except UnknownTerminalError:
# This user has not assigned a "Friendly Name" to this terminal identifier.
# Prompt them to enter a terminal name, then submit that "friendly name" to
# the Toopher API:
# api.assign_user_friendly_name_to_terminal(user_name, terminal_friendly_name, terminal_identifier)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is now called create_user_terminal

# Afterwards, re-try authentication
except PairingDeactivatedError:
# this user does not have an active pairing,
# typically because they deleted the pairing. You can prompt
# the user to re-pair with a new mobile device.
```

#### Dependencies
This library uses the python-oauth2 library to handle OAuth signing and httplib2 to make the web requests. If you install using pip (or easy_install) they'll be installed automatically for you.

Expand Down
56 changes: 53 additions & 3 deletions tests.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import unittest
import json
import os
import urlparse

import toopher
import unittest
import urlparse

class HttpClientMock(object):
def __init__(self, paths):
Expand Down Expand Up @@ -177,6 +177,56 @@ def test_access_arbitrary_keys_in_authentication_status(self):

self.assertEqual(auth_request.random_key, "84")

def test_disabled_user_raises_correct_error(self):
api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1')
api.client = HttpClientMock({
'https://toopher.test/v1/authentication_requests/initiate':
({'status': 409}, json.dumps(
{'error_code': 704,
'error_message': 'disabled user'}))})
with self.assertRaises(toopher.UserDisabledError):
auth_request = api.authenticate_by_user_name('disabled user', 'terminal name')

def test_unknown_user_raises_correct_error(self):
api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1')
api.client = HttpClientMock({
'https://toopher.test/v1/authentication_requests/initiate':
({'status': 409}, json.dumps(
{'error_code': 705,
'error_message': 'unknown user'}))})
with self.assertRaises(toopher.UserUnknownError):
auth_request = api.authenticate_by_user_name('unknown user', 'terminal name')

def test_unknown_terminal_raises_correct_error(self):
api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1')
api.client = HttpClientMock({
'https://toopher.test/v1/authentication_requests/initiate':
({'status': 409}, json.dumps(
{'error_code': 706,
'error_message': 'unknown terminal'}))})
with self.assertRaises(toopher.TerminalUnknownError):
auth_request = api.authenticate_by_user_name('user', 'unknown terminal name')

def test_disabled_pairing_raises_correct_error(self):
api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1')
api.client = HttpClientMock({
'https://toopher.test/v1/authentication_requests/initiate':
({'status': 409}, json.dumps(
{'error_code': 601,
'error_message': 'pairing has been deactivated'}))})
with self.assertRaises(toopher.PairingDeactivatedError):
auth_request = api.authenticate_by_user_name('user', 'terminal name')

def test_disabled_pairing_raises_correct_error(self):
api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1')
api.client = HttpClientMock({
'https://toopher.test/v1/authentication_requests/initiate':
({'status': 409}, json.dumps(
{'error_code': 601,
'error_message': 'pairing has not been authorized'}))})
with self.assertRaises(toopher.PairingDeactivatedError):
auth_request = api.authenticate_by_user_name('user', 'terminal name')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you test the set_enable_toopher_for_user method too? I think there should be three cases:

  • no user found -> error
  • multiple users found -> error
  • one user found -> success

def main():
unittest.main()

Expand Down
72 changes: 54 additions & 18 deletions toopher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
DEFAULT_BASE_URL = "https://api.toopher.com/v1"
VERSION = "1.0.6"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Psssst, bump up the VERSION to 1.1.0, please.


class ToopherApiError(Exception): pass
class UserDisabledError(ToopherApiError): pass
class UserUnknownError(ToopherApiError): pass
class TerminalUnknownError(ToopherApiError): pass
class PairingDeactivatedError(ToopherApiError): pass
error_codes_to_errors = {704: UserDisabledError,
705: UserUnknownError,
706: TerminalUnknownError}

class ToopherApi(object):
def __init__(self, key, secret, api_url=None):
Expand All @@ -25,7 +33,7 @@ def pair(self, pairing_phrase, user_name, **kwargs):
return PairingStatus(result)

def pair_sms(self, phone_number, user_name, phone_country=None):
uri = BASE_URL + "/pairings/create/sms"
uri = self.base_url + "/pairings/create/sms"
params = {'phone_number': phone_number,
'user_name': user_name}

Expand Down Expand Up @@ -60,30 +68,62 @@ def get_authentication_status(self, authentication_request_id):
return AuthenticationStatus(result)

def authenticate_with_otp(self, authentication_request_id, otp):
uri = BASE_URL + "/authentication_requests/" + authentication_request_id + '/otp_auth'
uri = self.base_url + "/authentication_requests/" + authentication_request_id + '/otp_auth'
params = {'otp' : otp}
result = self._request(uri, "POST", params)
return AuthenticationStatus(result)

def authenticate_by_user_name(self, user_name, terminal_name_extra, action_name=None, **kwargs):
kwargs.update(user_name=user_name, terminal_name_extra=terminal_name_extra)
return self.authenticate('', '', action_name, **kwargs)

def create_user_terminal(self, user_name, terminal_name, requester_terminal_id):
uri = self.base_url + '/user_terminals/create'
params = {'user_name': user_name,
'name': terminal_name,
'name_extra': requester_terminal_id}
result = self._request(uri, 'POST', params)

def set_enable_toopher_for_user(self, user_name, enabled):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Toopher Perl library called this method set_toopher_enabled_for_user.

uri = self.base_url + '/users'
users = self._request(uri, 'GET')
if len(users) > 1:
raise ToopherApiException('Multiple users with name = {}'.format(user_name))
elif not len(users):
raise ToopherApiException('No users with name = {}'.format(user_name))

uri = self.base_url + '/users/' + users[0]['id']
params = {'disable_toopher_auth': bool(enabled)}
result = self._request(uri, 'POST', params)

def _request(self, uri, method, params=None):
data = urllib.urlencode(params or {})
header_data = {'User-Agent':'Toopher-Python/{} (Python {})'.format(VERSION, sys.version.split()[0])}

resp, content = self.client.request(uri, method, data, headers=header_data)
if resp['status'] != '200':
try:
error_message = json.loads(content)['error_message']
except Exception:
error_message = content
raise ToopherApiError(error_message)

response, content = self.client.request(uri, method, data, headers=header_data)
try:
result = json.loads(content)
except Exception, e:
raise ToopherApiError("Response from server could not be decoded as JSON: %s" % e)
content = json.loads(content)
except ValueError:
raise ToopherApiError('Response from server could not be decoded as JSON.')

if int(response['status']) > 300:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 300 the right comparison? 300s are redirects; 400s are client errors; 500s are server errors. As you said, we're probably more concerned with errors, so we should check >= 400.

self._parse_request_error(content)

return content

return result
def _parse_request_error(self, content):
error_code = content['error_code']
error_message = content['error_message']
if error_code in error_codes_to_errors:
error = error_codes_to_errors[error_code]
raise error(error_message)

# TODO: Add an error code for PairingDeactivatedError.
if ('pairing has been deactivated' in error_message
or 'pairing has not been authorized' in error_message):
raise PairingDeactivatedError(error_message)

raise ToopherApiError(error_message)

class PairingStatus(object):
def __init__(self, json_response):
Expand Down Expand Up @@ -128,7 +168,3 @@ def __nonzero__(self):

def __getattr__(self, name):
return self._raw_data[name]


class ToopherApiError(Exception): pass