Permalink
Browse files

First commit

  • Loading branch information...
0 parents commit ad9f24d3153c132bbb5c4a95a30c2b3bdffe760e George Katsitadze committed Nov 10, 2012
Showing with 631 additions and 0 deletions.
  1. +44 −0 README.md
  2. +340 −0 backplane/__init__.py
  3. +16 −0 setup.py
  4. 0 tests/__init__.py
  5. +231 −0 tests/test_backplane.py
@@ -0,0 +1,44 @@
+Backplane Client Library for Python
+===================================
+
+This library integrates server side Backplane clients with the Backplane server protocol (https://github.com/janrain/janrain-backplane-2).
+
+Installation
+============
+```shell
+pip install git+https://github.com/geokat/backplane2-pyclient
+```
+
+Usage
+=====
+
+You should have client credentials for a Backplane server, with a bus provisioned for your use. If you have admin access to a backplane server, the following steps will get you set up:
+
+1. Provision a client (/v2/provision/client/update)
+2. Provision a bus (/v2/provision/bus/update)
+3. Grant client access to bus (/v2/provision/grant/add)
+
+For more information see the [Backplane server readme](https://github.com/janrain/janrain-backplane-2/blob/master/README20.md).
+
+Example:
+
+```python
+import backplane
+from backplane import ClientCredentials, Client, Message, channel_from_scope
+
+backplane.debug() # Enable debugging output
+
+client_credentials = ClientCredentials('https://backplane1.janrainbackplane.com', 'client id', 'secret')
+client = Client(client_credentials, True, 'bus:mybusname')
+access_token = client.get_regular_token('mybusname')
+channel = channel_from_scope(access_token.scope)
+message = Message('mybusname', channel, 'test', 'payload', True)
+client.post_message(message)
+
+# Poll server for messages
+message_wrapper = None
+while True:
+ # connect to Backplane server for 20 seconds at a time
+ message_wrapper = client.get_messages(message_wrapper, 20)
+ ...
+```
@@ -0,0 +1,340 @@
+"""
+Backplane2-pyclient Backplane API client library
+"""
+
+import sys
+import logging
+import base64
+import urllib
+import json
+import httplib2
+
+__version__ = '0.1'
+__author__ = 'George Katsitadze'
+
+TIMEOUT = 5
+
+logger = logging.getLogger(__name__)
+
+def debug(enable = True, level = 1):
+ """Turn on debug output for the module."""
+ httplib2.debuglevel = level
+ logging.basicConfig(level=logging.DEBUG)
+
+def channel_from_scope(scope):
+ """Helper method to extract channel ID from a Backplane scope."""
+ idx = scope.find('channel')
+ return scope[idx + 8:]
+
+
+class ClientCredentials(object):
+ """Backplane server client credentials.
+
+ Client ID and secret are usually issued by the server
+ admin. Access token can be (automatically) requested from the
+ server when initializing the client.
+ """
+ def __init__(self, server_url, client_id, client_secret, token = None):
+ self.server_url = server_url
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.token = token
+
+
+class Client(object):
+ """Backplane service client.
+
+ A (server-side) client that allows to connect to a Backplane
+ server, poll for and post messages.
+ """
+ def __init__(self, creds, initialize = True, scope = None):
+ """Initialize the client.
+
+ If necessary, initialize the client by requesting an access
+ token. If a particular scope is desired, it can be passed in;
+ otherwise the access token will be scoped for all resources
+ that the client has been granted access to.
+ """
+ assert not scope or initialize
+ self.credentials = creds
+ if initialize:
+ self.credentials.token = _get_token(self.credentials, scope)
+
+ def refresh_token(self, scope = None):
+ """Refresh the access token associated with client credentials.
+
+ Can be used to obtain a new access token. If a particular
+ scope is desired, it can be passed in; otherwise the new
+ access token will have the same scope as the old one.
+ """
+ self.credentials.token = _refresh_token(self.credentials, scope)
+
+ def get_token(self, scope = None):
+ """Get new access token.
+
+ Can be used to manually initialize client credentials. Can be
+ used to obtain a new token in lieu of an expired one (although
+ it is recommended to use refresh_token() instead).
+ """
+ return _get_token(self.credentials, scope)
+
+ def get_regular_token(self, bus):
+ """Get an anonymous access token.
+
+ This method will return an access token with a scope limited
+ to the provided bus and server generated channel name. This
+ endpoint is normally used for client side user-agent code
+ during Backplane initialization, but is useful here because it
+ is only possible to post against a channel that has been
+ generated by the server.
+ """
+ return _get_regular_token(self.credentials.server_url, bus)
+
+ def post_message(self, msg):
+ creds = self.credentials
+ url = creds.server_url + '/v2/message'
+ headers = {'Authorization':'Bearer ' + creds.token.access_token,
+ 'Content-type': 'application/json'}
+ data = json.dumps(msg, cls = _MessageEncoder)
+ _call_backplane(url, 'POST', data, headers)
+
+ def get_single_message(self, mid):
+ """Retrieve a single message body.
+
+ The argument can be either a complete message URL or an ID.
+ """
+ creds = self.credentials
+ if 'v2/message/' in mid:
+ url = mid
+ else:
+ url = creds.server_url + '/v2/message/' + mid
+
+ headers = {'Authorization':'Bearer ' + creds.token.access_token}
+ content = _call_backplane(url, 'GET', headers = headers)
+
+ return json.loads(content, object_hook = _as_message)
+
+ def get_messages(self, wrapper = None, block = None):
+ """Retrieve all messages in the scope of the access token.
+
+ This method will _not_ loop and retrieve all messages. It is
+ up to the caller of this method to use the returned (message)
+ Wrapper object and the more (messages) attribute to determine
+ if additional calls are required to retrieve all messages
+ known to be available at the time the call was made.
+
+ The Backplane server will only return a maximum of N messages
+ per call - N being defined by the particular server deployment.
+
+ Parameters:
+ wrapper -- message wrapper from a previous invocation of the
+ method (may be None)
+ block -- how long to block in seconds while waiting for (new)
+ messages (may be None)
+ """
+ creds = self.credentials
+ url = creds.server_url + '/v2/messages'
+ if wrapper:
+ url = wrapper.next_url
+
+ linger = 0
+ if block:
+ logger.debug('long polling for up to %s seconds' % block)
+ if wrapper:
+ url += '&block=' + str(block)
+ else:
+ url += '?block=' + str(block)
+
+ # Make sure the connection doesn't time out during long poll.
+ linger = block
+
+ headers = {'Authorization':'Bearer ' + creds.token.access_token}
+ content = _call_backplane(url, 'GET', headers = headers, extra = linger)
+ return json.loads(content, object_hook = _as_wrapper)
+
+
+class Token(object):
+ """An access token."""
+ def __init__(self, token, token_type, refresh_token, expires_in, scope):
+ self.access_token = token
+ self.type = token_type
+ self.refresh_token = refresh_token
+ self.expires_in = expires_in # seconds from the time it was issued
+ self.scope = scope
+
+
+class Message(object):
+ """A backplane message."""
+ def __init__(self, bus, channel, typ, payload, sticky,
+ url = None, source = None, expire = None):
+ self.bus = bus;
+ self.channel = channel;
+ self.payload = payload;
+ self.sticky = sticky;
+ self.type = typ;
+
+ # These fields are for messages received from the Backplane
+ # server. Do not set them for messages to be sent to the
+ # server.
+ if url:
+ self.messageURL = url
+ if source:
+ self.source = source
+ if expire:
+ self.expire = expire
+
+
+class Wrapper(object):
+ """A message wrapper.
+
+ Returned by the get_message() method.
+ """
+ def __init__(self, next_url, messages, more):
+ self.next_url = next_url;
+ self.messages = messages;
+ self.more = more;
+
+
+class BackplaneCallError(Exception):
+ """A Backplane server call error.
+
+ The exception raised if an error occurs while establishing a
+ connection to the Backplane server.
+ """
+ def __init__(self, desc = ''):
+ self.description = desc
+
+ def __str__(self):
+ return 'backplane call failed: ' + self.description
+
+
+class BackplaneError(Exception):
+ """Generic exception for Backplane server errors.
+
+ Attributes:
+ response -- (error) response received from the server
+ """
+ def __init__(self, resp = ''):
+ self.response = resp
+
+ def __str__(self):
+ return self.response
+
+
+class UnauthorizedScopeError(BackplaneError):
+ """An unauthorized scope access error.
+
+ The exception raised when trying to access a Backplane resource
+ (bus) we have not been granted access to.
+ """
+ pass
+
+
+class ExpiredTokenError(BackplaneError):
+ """An expired token error.
+
+ The exception raised when trying to access a Backplane resource
+ with an expired token."""
+ pass
+
+
+class InvalidTokenError(BackplaneError):
+ """An invalid token error.
+
+ The exception raised when trying to access a Backplane resource
+ with an invalid token.
+ """
+ pass
+
+
+#---------------------------------------------------------------------------
+# Internal use
+
+
+class _MessageEncoder(json.JSONEncoder):
+ """Serialize Message objects as JSON."""
+ def default(self, obj):
+ if not isinstance(obj, Message):
+ return super(_MessageEncoder, self).default(obj)
+
+ return {"message" : obj.__dict__}
+
+
+def _as_message(d):
+ """A hook used to deserialize Messages from JSON."""
+ try:
+ return Message(d['bus'], d['channel'], d['type'], d['payload'],
+ d['sticky'], d['messageURL'], d['source'], d['expire'])
+ except KeyError:
+ # Skip the payload (and any other unknown stuff).
+ return d
+
+def _as_wrapper(d):
+ """A hook used to deserialize (message) Wrappers from JSON."""
+ try:
+ return Wrapper(d['nextURL'], d['messages'], d['moreMessages'])
+ except KeyError:
+ # Try for an embedded message.
+ return _as_message(d)
+
+def _refresh_token(creds, scope = None):
+ return _get_token(creds, scope, grant_type = 'refresh_token')
+
+def _get_token(creds, scope = None, grant_type = 'client_credentials'):
+ assert grant_type != 'refresh_token' or creds.token
+ url = creds.server_url + '/v2/token'
+ basic = base64.b64encode(creds.client_id + ':' + creds.client_secret)
+ headers = {'Authorization': 'Basic ' + basic,
+ 'Content-Type': 'application/x-www-form-urlencoded'}
+ data = {'grant_type': grant_type}
+ if (scope):
+ data['scope'] = scope
+
+ if grant_type == 'refresh_token':
+ data['refresh_token'] = creds.token.refresh_token
+
+ content = _call_backplane(url, 'POST', urllib.urlencode(data), headers)
+ return _json_to_token(content)
+
+def _get_regular_token(server_url, bus):
+ url = server_url + '/v2/token?callback=f&bus='+ bus
+ content = _call_backplane(url, 'GET')
+ jsn = content[2:-2]
+ # The response for this call does not flag errors in the HTTP
+ # status code
+ if json.loads(jsn).get('error'):
+ raise BackplaneError(jsn)
+
+ return _json_to_token(jsn)
+
+def _call_backplane(url, method, body = None, headers = None, extra = 0):
+ #h = httplib2.Http(timeout = TIMEOUT + extra, disable_ssl_certificate_validation=True)
+ h = httplib2.Http(timeout = TIMEOUT + extra)
+ try:
+ resp, content = h.request(url, method, body, headers)
+ except:
+ msg = str(sys.exc_info()[1])
+ logger.error('call to %s failed: ' % url + msg)
+ raise BackplaneCallError(msg)
+
+ if content:
+ logger.debug('server response: ' + content)
+
+ if not resp['status'] in ['200', '201']:
+ logger.warn('server returned error: ' + content)
+ if 'unauthorized scope' in content:
+ raise UnauthorizedScopeError(content)
+ elif 'expired token' in content:
+ raise ExpiredTokenError(content)
+ elif 'invalid token' in content:
+ raise InvalidTokenError(content)
+ else:
+ raise BackplaneError(content)
+
+ return content
+
+def _json_to_token(json_string):
+ t = json.loads(json_string)
+ # Only the first two are guaranteed, according to the spec.
+ return Token(t['access_token'], t['token_type'],
+ t.get('refresh_token'), t.get('expires_in'), t.get('scope'))
Oops, something went wrong.

0 comments on commit ad9f24d

Please sign in to comment.