diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd9c726 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 diff --git a/.travis.yml b/.travis.yml index ca461a5..5daf16d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,5 +5,10 @@ python: - "2.7" # - "3.2" - "3.3" -install: "python setup.py develop" +install: + - "python setup.py develop" + - "pip install aiohttp" + - "pip install tornado" + - "pip install urlfetch" + - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi script: "python setup.py test" diff --git a/README.md b/README.md index d931212..d226cc1 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ constructor arguments which identify your Pusher app. You can find them by going to "API Keys" on your app at https://app.pusher.com. ```python -from pusher import Config, Pusher -pusher = Pusher(config=Config(app_id=u'4', key=u'key', secret=u'secret')) +from pusher import Pusher +pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret') ``` You can then trigger events to channels. Channel and event names may only @@ -50,6 +50,31 @@ contain alphanumeric characters, `-` and `_`: pusher.trigger(u'a_channel', u'an_event', {u'some': u'data'}) ``` +## Configuration + +```python +from pusher import Pusher +pusher = Pusher(app_id, key, secret, config=None, backend=None) +``` + +|Argument |Description | +|:-:|:-:| +|app_id `String` |**Required**
The Pusher application ID | +|key `String` |**Required**
The Pusher application key | +|secret `String` |**Required**
The Pusher application secret token | +|host `String` | **Default:`None`**
The host to connect to | +|port `int` | **Default:`None`**
Which port to connect to | +|ssl `bool` | **Default:`False`**
Use HTTPS | +|cluster `String` | **Default:`None`**
Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host | +|backend `Object` | an object that responds to the send_request(request) method. If none is provided, a `python.sync.SynchronousBackend` instance is created. | + +##### Example + +```py +from pusher import Pusher, Config +pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret', Config(ssl=True, cluster=u'eu')) +``` + Triggering Events ----------------- @@ -159,13 +184,13 @@ pusher.users_info(u'presence-chatroom') Authenticating Channel Subscription ----------------- -#### `Config::authenticate_subscription` +#### `Pusher::authenticate` In order for users to subscribe to a private- or presence-channel, they must be authenticated by your server. The client will make a POST request to an endpoint (either "/pusher/auth" or any which you specify) with a body consisting of the channel's name and socket_id. -Using your `Config` instance, with which you initialized `Pusher`, you can generate an authentication signature. Having responded to the request with this signature, the subscription will be authenticated. +Using your `Pusher` instance, with which you initialized `Pusher`, you can generate an authentication signature. Having responded to the request with this signature, the subscription will be authenticated. |Argument |Description | |:-:|:-:| @@ -182,8 +207,7 @@ Using your `Config` instance, with which you initialized `Pusher`, you can gener ###### Private Channels ```python -config = pusher.config -auth = config.authenticate_subscription( +auth = pusher.authenticate_subscription( channel=u"private-channel", @@ -195,9 +219,7 @@ auth = config.authenticate_subscription( ###### Presence Channels ```python -config = pusher.config - -auth = config.authenticate_subscription( +auth = pusher.authenticate_subscription( channel=u"presence-channel", @@ -216,9 +238,9 @@ auth = config.authenticate_subscription( Receiving Webhooks ----------------- -If you have webhooks set up to POST a payload to a specified endpoint, you may wish to validate that these are actually from Pusher. The `Config` object achieves this by checking the authentication signature in the request body using your application credentials. +If you have webhooks set up to POST a payload to a specified endpoint, you may wish to validate that these are actually from Pusher. The `Pusher` object achieves this by checking the authentication signature in the request body using your application credentials. -#### `Config::validate_webhook` +#### `Pusher::validate_webhook` |Argument |Description | |:-:|:-:| @@ -233,7 +255,7 @@ If you have webhooks set up to POST a payload to a specified endpoint, you may w ##### Example ```python -webhook = pusher.config.validate_webhook( +webhook = pusher.validate_webhook( key="key_sent_in_header", @@ -253,4 +275,4 @@ To run the tests run `python setup.py test` License ------- -Copyright (c) 2014 Pusher Ltd. See LICENSE for details. +Copyright (c) 2015 Pusher Ltd. See LICENSE for details. diff --git a/pusher/__init__.py b/pusher/__init__.py index 9a313d7..8cc9630 100644 --- a/pusher/__init__.py +++ b/pusher/__init__.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- -from .config import Config from .pusher import Pusher __all__ = [ - 'Config', 'Pusher', ] diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index f43eabb..d194868 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -1,27 +1,29 @@ # -*- coding: utf-8 -*- import aiohttp -import pusher +import asyncio -class AsyncIOBackend: - """Adapter for the aiohttp module. - - This backend is only availble for python 3 users and doesn't support SSL. +from pusher.http import process_response - :param config: pusher.Config instance - """ +class AsyncIOBackend: def __init__(self, config): + """Adapter for the requests module. + + :param config: pusher.Pusher object + """ self.config = config - if config.ssl: - raise NotImplementedError("SSL not supported for this backend") + self.conn = aiohttp.TCPConnector() def send_request(self, request): method = request.method - url = "http://%s:%s%s" % (self.config.host, self.config.port, request.path) + url = "%s%s" % (request.base_url, request.path) params = request.query_params data = request.body headers = request.headers - response = yield from aiohttp.request(method, url, params=params, data=data, headers=headers) + response = yield from asyncio.wait_for( + aiohttp.request(method, url, params=params, data=data, headers=headers, connector=self.conn), + timeout=self.config.timeout + ) body = yield from response.read_and_close() - return pusher.process_response(response.status, body.decode('utf8')) + return process_response(response.status, body.decode('utf8')) diff --git a/pusher/config.py b/pusher/config.py deleted file mode 100644 index b8d5492..0000000 --- a/pusher/config.py +++ /dev/null @@ -1,193 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (print_function, unicode_literals, absolute_import, - division) -from pusher.util import app_id_re, channel_name_re, text - -import hashlib -import hmac -import json -import os -import re -import six -import time - -try: - compare_digest = hmac.compare_digest -except AttributeError: - # Not secure when the length is supposed to be kept secret - def compare_digest(a, b): - if len(a) != len(b): - return False - return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 - -class Config(object): - """The Config class holds the pusher credentials and other connection - infos to the HTTP API. - - :param app_id: The Pusher application ID - :param key: The Pusher application key - :param secret: The Pusher application secret - :param ssl: Whenever to use SSL or plain HTTP - :param host: Used for custom host destination - :param port: Used for custom port destination - :param cluster: Convention for other clusters than the main Pusher-one. - Eg: 'eu' will resolve to the api-eu.pusherapp.com host - - Usage:: - - >> from pusher import Config - >> c = Config('455', 'mykey', 'mysecret') - """ - def __init__(self, app_id, key, secret, ssl=False, host=None, port=None, cluster=None): - if not isinstance(app_id, six.text_type): - raise TypeError("App ID should be %s" % text) - - if not isinstance(key, six.text_type): - raise TypeError("Key should be %s" % text) - - if not isinstance(secret, six.text_type): - raise TypeError("Secret should be %s" % text) - - if not app_id_re.match(app_id): - raise ValueError("Invalid app id") - - if port and not isinstance(port, six.integer_types): - raise TypeError("Port should be a number") - - if not isinstance(ssl, bool): - raise TypeError("SSL should be a boolean") - - self.app_id = app_id - self.key = key - self.secret = secret - - if host: - if not isinstance(host, six.text_type): - raise TypeError("Host should be %s" % text) - - self.host = host - elif cluster: - if not isinstance(cluster, six.text_type): - raise TypeError("Cluster should be %s" % text) - - self.host = "api-%s.pusher.com" % cluster - else: - self.host = "api.pusherapp.com" - - self.port = port or (443 if ssl else 80) - self.ssl = ssl - - @classmethod - def from_url(cls, url): - """Alternate constructor that extracts the information from a URL. - - :param url: String containing a URL - - Usage:: - - >> from pusher import Config - >> c = Config.from_url("http://mykey:mysecret@api.pusher.com/apps/432") - """ - m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", url) - if not m: - raise Exception("Unparsable url: %s" % url) - ssl = m.group(1) == 'https' - return cls(key=m.group(2), secret=m.group(3), host=m.group(4), app_id=m.group(5), ssl=ssl) - - @classmethod - def from_env(cls, env='PUSHER_URL'): - """Alternate constructor that extracts the information from an URL - stored in an environment variable. The pusher heroku addon will set - the PUSHER_URL automatically when installed for example. - - :param env: Name of the environment variable - - Usage:: - - >> from pusher import Config - >> c = Config.from_env("PUSHER_URL") - """ - val = os.environ.get(env) - if not val: - raise Exception("Environment variable %s not found" % env) - return cls.from_url(six.text_type(val)) - - @property - def scheme(self): - """Returns "http" or "https" scheme depending on the ssl setting.""" - return 'https' if self.ssl else 'http' - - def authenticate_subscription(self, channel, socket_id, custom_data=None): - """Used to generate delegated client subscription token. - - :param channel: name of the channel to authorize subscription to - :param socket_id: id of the socket that requires authorization - :param custom_data: used on presence channels to provide user info - """ - if not isinstance(channel, six.text_type): - raise TypeError('Channel should be %s' % text) - - if not channel_name_re.match(channel): - raise ValueError('Channel should be a valid channel, got: %s' % channel) - - if not isinstance(socket_id, six.text_type): - raise TypeError('Socket ID should %s' % text) - - if custom_data: - custom_data = json.dumps(custom_data) - - string_to_sign = "%s:%s" % (socket_id, channel) - - if custom_data: - string_to_sign += ":%s" % custom_data - - signature = hmac.new(self.secret.encode('utf8'), string_to_sign.encode('utf8'), hashlib.sha256).hexdigest() - - auth = "%s:%s" % (self.key, signature) - result = {'auth': auth} - - if custom_data: - result['channel_data'] = custom_data - - return result - - def validate_webhook(self, key, signature, body): - """Used to validate incoming webhook messages. When used it guarantees - that the sender is Pusher and not someone else impersonating it. - - :param key: key used to sign the body - :param signature: signature that was given with the body - :param body: content that needs to be verified - """ - if not isinstance(key, six.text_type): - raise TypeError('key should be %s' % text) - - if not isinstance(signature, six.text_type): - raise TypeError('signature should be %s' % text) - - if not isinstance(body, six.text_type): - raise TypeError('body should be %s' % text) - - if key != self.key: - return None - - generated_signature = six.text_type(hmac.new(self.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) - - if not compare_digest(generated_signature, signature): - return None - - try: - body_data = json.loads(body) - except ValueError: - return None - - time_ms = body_data.get('time_ms') - if not time_ms: - return None - - print(abs(time.time()*1000 - time_ms)) - if abs(time.time()*1000 - time_ms) > 300000: - return None - - return body_data diff --git a/pusher/errors.py b/pusher/errors.py new file mode 100644 index 0000000..462e355 --- /dev/null +++ b/pusher/errors.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +class PusherError(Exception): + pass + +class PusherBadRequest(PusherError): + pass + +class PusherBadAuth(PusherError): + pass + +class PusherForbidden(PusherError): + pass + +class PusherBadStatus(PusherError): + pass \ No newline at end of file diff --git a/pusher/request.py b/pusher/http.py similarity index 60% rename from pusher/request.py rename to pusher/http.py index 9ff108a..5b12130 100644 --- a/pusher/request.py +++ b/pusher/http.py @@ -2,25 +2,63 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import GET, POST +from pusher.errors import * +from pusher.signature import sign import copy import hashlib -import hmac import json import six import time +GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE" + +class RequestMethod(object): + def __init__(self, pusher, f): + self.pusher = pusher + self.f = f + + def __call__(self, *args, **kwargs): + return self.pusher.http.send_request(self.make_request(*args, **kwargs)) + + def make_request(self, *args, **kwargs): + return self.f(self.pusher, *args, **kwargs) + +def doc_string(doc): + def decorator(f): + f.__doc__ = doc + return f + return decorator + +def request_method(f): + @property + @doc_string(f.__doc__) + def wrapped(self): + return RequestMethod(self, f) + return wrapped + def make_query_string(params): return '&'.join(map('='.join, sorted(params.items(), key=lambda x: x[0]))) +def process_response(status, body): + if status == 200: + return json.loads(body) + elif status == 400: + raise PusherBadRequest(body) + elif status == 401: + raise PusherBadAuth(body) + elif status == 403: + raise PusherForbidden(body) + else: + raise PusherBadStatus("%s: %s" % (status, body)) + class Request(object): """Represents the request to be made to the Pusher API. An instance of that object is passed to the backend's send_request method for each request. - :param config: an instance of pusher.Config + :param config: an instance of pusher.Pusher :param method: HTTP method as a string :param path: The target path on the destination host :param params: Query params or body depending on the method @@ -55,10 +93,7 @@ def _generate_auth(self): make_query_string(self.query_params) ]) - secret = self.config.secret.encode('utf8') - message = auth_string.encode('utf8') - - self.query_params['auth_signature'] = six.text_type(hmac.new(secret, message, hashlib.sha256).hexdigest()) + self.query_params['auth_signature'] = sign(self.config.secret, auth_string) @property def query_string(self): @@ -70,7 +105,11 @@ def signed_path(self): @property def url(self): - return "%s://%s:%s%s" % (self.config.scheme, self.config.host, self.config.port, self.signed_path) + return "%s%s" % (self.base_url, self.signed_path) + + @property + def base_url(self): + return "%s://%s:%s" % (self.config.scheme, self.config.host, self.config.port) @property def headers(self): diff --git a/pusher/pusher.py b/pusher/pusher.py index 3d5f774..e4dd98c 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -2,44 +2,20 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.config import Config -from pusher.request import Request -from pusher.sync import SynchronousBackend -from pusher.util import GET, POST, text, validate_channel +from pusher.http import GET, POST, Request, request_method +from pusher.signature import sign, verify +from pusher.requests import RequestsBackend +from pusher.util import ensure_text, validate_channel, app_id_re, pusher_url_re, channel_name_re import collections +import hashlib import json +import os +import re import six - -class RequestMethod(object): - def __init__(self, pusher, f): - self.pusher = pusher - self.f = f - - def __call__(self, *args, **kwargs): - return self.pusher.backend.send_request(self.make_request(*args, **kwargs)) - - def make_request(self, *args, **kwargs): - return self.f(self.pusher, *args, **kwargs) - -def doc_string(doc): - def decorator(f): - f.__doc__ = doc - return f - return decorator - -def request_method(f): - @property - @doc_string(f.__doc__) - def wrapped(self): - return RequestMethod(self, f) - return wrapped +import time def join_attributes(attributes): - for attr in attributes: - if not isinstance(attr, six.text_type): - raise TypeError('Each attr should be %s' % text) - return six.text_type(',').join(attributes) class Pusher(object): @@ -48,16 +24,93 @@ class Pusher(object): This client supports various backend adapters to support various http libraries available in the python ecosystem. - :param config: a pusher.Config instance - :param backend: an object that responds to the send_request(request) - method. If none is provided, a - python.sync.SynchronousBackend instance is created. + :param app_id: a pusher application identifier + :param key: a pusher application key + :param secret: a pusher application secret token + :param ssl: Whenever to use SSL or plain HTTP + :param host: Used for custom host destination + :param port: Used for custom port destination + :param timeout: Request timeout (in seconds) + :param cluster: Convention for other clusters than the main Pusher-one. + Eg: 'eu' will resolve to the api-eu.pusherapp.com host + :param backend: an http adapter class (AsyncIOBackend, RequestsBackend, SynchronousBackend, TornadoBackend) + :param backend_options: additional backend """ - def __init__(self, config, backend=None): - if not isinstance(config, Config): - raise TypeError("config should be a pusher.Config object") - self.backend = backend or SynchronousBackend(config) - self.config = config + def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=RequestsBackend, **backend_options): + self._app_id = ensure_text(app_id, "app_id") + if not app_id_re.match(self._app_id): + raise ValueError("Invalid app id") + + self._key = ensure_text(key, "key") + self._secret = ensure_text(secret, "secret") + + if not isinstance(ssl, bool): + raise TypeError("SSL should be a boolean") + self._ssl = ssl + + if host: + self._host = ensure_text(host, "host") + elif cluster: + self._host = six.text_type("api-%s.pusher.com") % ensure_text(cluster, "cluster") + else: + self._host = six.text_type("api.pusherapp.com") + + if port and not isinstance(port, six.integer_types): + raise TypeError("port should be an integer") + self._port = port or (443 if ssl else 80) + + if not isinstance(timeout, six.integer_types): + raise TypeError("timeout should be an integer") + self._timeout = timeout + + self.http = backend(self, **backend_options) + + @classmethod + def from_url(cls, url, **options): + """Alternate constructor that extracts the information from a URL. + + :param url: String containing a URL + + Usage:: + + >> from pusher import Pusher + >> p = Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") + """ + m = pusher_url_re.match(ensure_text(url, "url")) + if not m: + raise Exception("Unparsable url: %s" % url) + ssl = m.group(1) == 'https' + + options_ = { + 'key': m.group(2), + 'secret': m.group(3), + 'host': m.group(4), + 'app_id': m.group(5), + 'ssl': ssl, + } + options_.update(options) + + + return cls(**options_) + + @classmethod + def from_env(cls, env='PUSHER_URL', **options): + """Alternate constructor that extracts the information from an URL + stored in an environment variable. The pusher heroku addon will set + the PUSHER_URL automatically when installed for example. + + :param env: Name of the environment variable + + Usage:: + + >> from pusher import Pusher + >> c = Pusher.from_env("PUSHER_URL") + """ + val = os.environ.get(env) + if not val: + raise Exception("Environment variable %s not found" % env) + + return cls.from_url(val, **options) @request_method def trigger(self, channels, event_name, data, socket_id=None): @@ -67,25 +120,25 @@ def trigger(self, channels, event_name, data, socket_id=None): http://pusher.com/docs/rest_api#method-post-event ''' - if isinstance(channels, dict) or not (isinstance(channels, six.string_types) or isinstance(channels, (collections.Sized, collections.Iterable))): - raise TypeError("Expected a single string or collection of channels (each channel should be %s)" % text) - if isinstance(channels, six.string_types): channels = [channels] + if isinstance(channels, dict) or not isinstance(channels, (collections.Sized, collections.Iterable)): + raise TypeError("Expected a single or a list of channels") + if len(channels) > 10: raise ValueError("Too many channels") - for channel in channels: - validate_channel(channel) + channels = list(map(validate_channel, channels)) - if not isinstance(event_name, six.text_type): - raise TypeError("event_name should be %s" % text) + event_name = ensure_text(event_name, "event_name") if len(event_name) > 200: raise ValueError("event_name too long") - if not isinstance(data, six.text_type): + if isinstance(data, six.string_types): + data = ensure_text(data, "data") + else: data = json.dumps(data) if len(data) > 10240: @@ -97,10 +150,9 @@ def trigger(self, channels, event_name, data, socket_id=None): 'data': data } if socket_id: - if not isinstance(socket_id, six.text_type): - raise TypeError("Socket ID should be %s" % text) - params['socket_id'] = socket_id - return Request(self.config, POST, "/apps/%s/events" % self.config.app_id, params) + params['socket_id'] = ensure_text(socket_id, "socket_id") + + return Request(self, POST, "/apps/%s/events" % self.app_id, params) @request_method def channels_info(self, prefix_filter=None, attributes=[]): @@ -113,8 +165,8 @@ def channels_info(self, prefix_filter=None, attributes=[]): if attributes: params['info'] = join_attributes(attributes) if prefix_filter: - params['filter_by_prefix'] = prefix_filter - return Request(self.config, GET, "/apps/%s/channels" % self.config.app_id, params) + params['filter_by_prefix'] = ensure_text(prefix_filter, "prefix_filter") + return Request(self, GET, six.text_type("/apps/%s/channels") % self.app_id, params) @request_method def channel_info(self, channel, attributes=[]): @@ -128,7 +180,7 @@ def channel_info(self, channel, attributes=[]): params = {} if attributes: params['info'] = join_attributes(attributes) - return Request(self.config, GET, "/apps/%s/channels/%s" % (self.config.app_id, channel), params) + return Request(self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) @request_method def users_info(self, channel): @@ -139,4 +191,101 @@ def users_info(self, channel): ''' validate_channel(channel) - return Request(self.config, GET, "/apps/%s/channels/%s/users" % (self.config.app_id, channel)) + return Request(self, GET, "/apps/%s/channels/%s/users" % (self.app_id, channel)) + + def authenticate(self, channel, socket_id, custom_data=None): + """Used to generate delegated client subscription token. + + :param channel: name of the channel to authorize subscription to + :param socket_id: id of the socket that requires authorization + :param custom_data: used on presence channels to provide user info + """ + channel = validate_channel(channel) + + if not channel_name_re.match(channel): + raise ValueError('Channel should be a valid channel, got: %s' % channel) + + socket_id = ensure_text(socket_id, "socket_id") + + if custom_data: + custom_data = json.dumps(custom_data) + + string_to_sign = "%s:%s" % (socket_id, channel) + + if custom_data: + string_to_sign += ":%s" % custom_data + + signature = sign(self.secret, string_to_sign) + + auth = "%s:%s" % (self.key, signature) + result = {'auth': auth} + + if custom_data: + result['channel_data'] = custom_data + + return result + + def validate_webhook(self, key, signature, body): + """Used to validate incoming webhook messages. When used it guarantees + that the sender is Pusher and not someone else impersonating it. + + :param key: key used to sign the body + :param signature: signature that was given with the body + :param body: content that needs to be verified + """ + key = ensure_text(key, "key") + signature = ensure_text(signature, "signature") + body = ensure_text(body, "body") + + if key != self.key: + return None + + if not verify(self.secret, body, signature): + return None + + try: + body_data = json.loads(body) + except ValueError: + return None + + time_ms = body_data.get('time_ms') + if not time_ms: + return None + + print(abs(time.time()*1000 - time_ms)) + if abs(time.time()*1000 - time_ms) > 300000: + return None + + return body_data + + @property + def app_id(self): + return self._app_id + + @property + def key(self): + return self._key + + @property + def secret(self): + return self._secret + + @property + def host(self): + return self._host + + @property + def port(self): + return self._port + + @property + def timeout(self): + return self._timeout + + @property + def ssl(self): + return self._ssl + + @property + def scheme(self): + return 'https' if self.ssl else 'http' diff --git a/pusher/requests.py b/pusher/requests.py index 520d904..16fd2c7 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -2,26 +2,29 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import process_response +from pusher.http import process_response import requests class RequestsBackend(object): """Adapter for the requests module. - :param config: an instance of pusher.Config + :param config: pusher.Pusher object :param options: key-value passed into the requests.request constructor """ def __init__(self, config, **options): + self.config = config self.options = {'verify': True} self.options.update(options) + self.session = requests.Session() def send_request(self, request): - resp = requests.request( + resp = self.session.request( request.method, request.url, headers=request.headers, data=request.body, + timeout=self.config.timeout, **self.options ) return process_response(resp.status_code, resp.text) diff --git a/pusher/signature.py b/pusher/signature.py new file mode 100644 index 0000000..146c5db --- /dev/null +++ b/pusher/signature.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +import hashlib +import hmac +import six + +try: + compare_digest = hmac.compare_digest +except AttributeError: + # Not secure when the length is supposed to be kept secret + def compare_digest(a, b): + if len(a) != len(b): + return False + return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 + +def sign(secret, string_to_sign): + return six.text_type( + hmac.new( + secret.encode('utf8'), + string_to_sign.encode('utf8'), + hashlib.sha256 + ) + .hexdigest() + ) + +def verify(secret, string_to_sign, signature): + return compare_digest(signature, sign(secret, string_to_sign)) diff --git a/pusher/sync.py b/pusher/sync.py deleted file mode 100644 index 70ff9f4..0000000 --- a/pusher/sync.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (print_function, unicode_literals, absolute_import, - division) -from pusher.util import PusherError, process_response -from six.moves import http_client - -import socket -import ssl -import sys - -class SynchronousBackend(object): - """Adapter for the standard-library http client. - - :param config: an instance of pusher.Config - :param timeout: configurable timeout for the TCP connection - """ - def __init__(self, config, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): - self.config = config - self.timeout = timeout - if config.ssl: - if sys.version_info < (3,4): - raise NotImplementedError("SSL requires python >= 3.4, earlier versions don't support certificate validation") - - ctx = ssl.create_default_context() - self.http = http_client.HTTPSConnection(self.config.host, self.config.port, timeout=self.timeout, context=ctx) - else: - self.http = http_client.HTTPConnection(self.config.host, self.config.port, timeout=self.timeout) - - def send_request(self, request): - try: - self.http.request(request.method, request.signed_path, request.body, {"Content-Type": "application/json"}) - resp = self.http.getresponse() - body = resp.read().decode('utf8') - except http_client.HTTPException as e: - raise PusherError(repr(e)) - - return process_response(resp.status, body) diff --git a/pusher/tornado.py b/pusher/tornado.py index 3ccf439..662e6d4 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -2,26 +2,43 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) +from pusher.http import process_response +import six import tornado import tornado.httpclient +from tornado.concurrent import TracebackFuture class TornadoBackend(object): """Adapter for the tornado.httpclient module. - :param config: a pusher.Config instance - :param timeout: configurable timeout for the connection + :param config: pusher.Pusher object + :param kwargs: options for the httpclient.HTTPClient constructor """ - def __init__(self, config, timeout=None): + def __init__(self, config, **kwargs): self.config = config - self.timeout = timeout - if self.config.ssl: - raise NotImplementedError("SSL not supported for this backend") + self.http = tornado.httpclient.AsyncHTTPClient(**kwargs) def send_request(self, request): method = request.method - url = "http://%s:%s%s?%s" % (self.config.host, self.config.port, request.path, request.query_string) data = request.body headers = {'Content-Type': 'application/json'} + future = TracebackFuture() + + def process_response_future(response): + if response.exc_info() is not None: + future.set_exc_info(response.exc_info()) + elif response.exception() is not None: + future.set_exception(response.exception()) + else: + result = response.result() + code = result.code + body = (result.body or b'').decode('utf8') + future.set_result(process_response(code, body)) + + request = tornado.httpclient.HTTPRequest(request.url, method=method, body=data, headers=headers, request_timeout=self.config.timeout) + response_future = self.http.fetch(request, raise_error=False) + response_future.add_done_callback(process_response_future) + + return future - return tornado.httpclient.HTTPRequest(url, method=method, body=data, request_timeout=self.timeout, headers=headers) diff --git a/pusher/urlfetch.py b/pusher/urlfetch.py new file mode 100644 index 0000000..a507549 --- /dev/null +++ b/pusher/urlfetch.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from __future__ import (print_function, unicode_literals, absolute_import, + division) + +import urlfetch +from pusher.http import process_response + +class URLFetchBackend(object): + """Adapter for the URLFetch Module. Necessary for using this library with Google + App Engine""" + + def __init__(self, config, **options): + self.config = config + self.options = options + + def send_request(self, request): + resp = urlfetch.fetch( + url=request.url, + headers=request.headers, + method=request.method, + data=request.body, + deadline=self.config.timeout, + **self.options + ) + return process_response(resp.status_code, resp.content) \ No newline at end of file diff --git a/pusher/util.py b/pusher/util.py index d48d300..d91b1df 100644 --- a/pusher/util.py +++ b/pusher/util.py @@ -10,47 +10,27 @@ channel_name_re = re.compile('^[-a-zA-Z0-9_=@,.;]+$') app_id_re = re.compile('^[0-9]+$') - -GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE" +pusher_url_re = re.compile('(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)') if sys.version_info < (3,): text = 'a unicode string' else: text = 'a string' -class PusherError(Exception): - pass - -class PusherBadRequest(PusherError): - pass - -class PusherBadAuth(PusherError): - pass - -class PusherForbidden(PusherError): - pass - -class PusherBadStatus(PusherError): - pass - -def process_response(status, body): - if status == 200: - return json.loads(body) - elif status == 400: - raise PusherBadRequest(body) - elif status == 401: - raise PusherBadAuth(body) - elif status == 403: - raise PusherForbidden(body) - else: - raise PusherBadStatus("%s: %s" % (status, body)) +def ensure_text(obj, name): + if isinstance(obj, six.text_type): + return obj + if isinstance(obj, six.string_types): + return six.text_type(obj) + raise TypeError("%s should be %s" % (name, text)) def validate_channel(channel): - if not isinstance(channel, six.text_type): - raise TypeError("Channel should be %s" % text) + channel = ensure_text(channel, "channel") if len(channel) > 200: - raise ValueError("Channel too long") + raise ValueError("Channel too long: %s" % channel) if not channel_name_re.match(channel): raise ValueError("Invalid Channel: %s" % channel) + + return channel diff --git a/pusher_tests/aio/aiohttp_adapter_test.py b/pusher_tests/aio/aiohttp_adapter_test.py new file mode 100644 index 0000000..6fe84d2 --- /dev/null +++ b/pusher_tests/aio/aiohttp_adapter_test.py @@ -0,0 +1,24 @@ +from __future__ import print_function, absolute_import, division + +import pusher +import pusher.aiohttp +import asyncio +import unittest +import httpretty + +class TestAIOHTTPBackend(unittest.TestCase): + + def setUp(self): + self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', + backend=pusher.aiohttp.AsyncIOBackend) + + @httpretty.activate + def test_trigger_aio_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = yield from self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() diff --git a/pusher_tests/test_aiohttp_adapter.py b/pusher_tests/test_aiohttp_adapter.py new file mode 100644 index 0000000..60c6ce7 --- /dev/null +++ b/pusher_tests/test_aiohttp_adapter.py @@ -0,0 +1,4 @@ +import sys + +if sys.version_info >= (3,): + from .aio.aiohttp_adapter_test import * diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 71e903d..8c2a5b3 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -8,7 +8,7 @@ import six import unittest -from pusher import Config +from pusher import Pusher try: import unittest.mock as mock @@ -17,201 +17,78 @@ class TestConfig(unittest.TestCase): def test_should_be_constructable(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) - def test_app_id_should_be_text(self): - self.assertRaises(TypeError, lambda: Config(key=u'key', secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=4, key=u'key', secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=b'4', key=u'key', secret=u'secret', ssl=False)) + def test_app_id_should_be_text_if_present(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret', ssl=False)) - def test_key_should_be_text(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=4, secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=b'key', secret=u'secret', ssl=False)) + def test_key_should_be_text_if_present(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret', ssl=False)) - def test_secret_should_be_text(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=4, ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret', ssl=False)) - - def test_ssl_should_be_required(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret')) + def test_secret_should_be_text_if_present(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4, ssl=False)) def test_ssl_should_be_boolean(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=4)) def test_host_should_be_text(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=b'foo')) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) def test_port_should_be_number(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=400) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=400) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=u'400')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=u'400')) def test_cluster_should_be_text(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=b'eu')) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) def test_host_behaviour(self): - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) self.assertEqual(conf.host, u'api.pusherapp.com', u'default host should be correct') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') self.assertEqual(conf.host, u'api-eu.pusher.com', u'host should be overriden by cluster setting') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') self.assertEqual(conf.host, u'foo', u'host should be overriden by host setting') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') self.assertEqual(conf.host, u'plah', u'host should be used in preference to cluster') def test_port_behaviour(self): - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) self.assertEqual(conf.port, 443, u'port should be 443 for ssl') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) self.assertEqual(conf.port, 80, u'port should be 80 for non ssl') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False, port=4000) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False, port=4000) self.assertEqual(conf.port, 4000, u'the port setting override the default') def test_initialize_from_url(self): - self.assertRaises(TypeError, lambda: Config.from_url(4)) - self.assertRaises(TypeError, lambda: Config.from_url(b'http://foo:bar@host/apps/4')) - self.assertRaises(Exception, lambda: Config.from_url(u'httpsahsutaeh')) + self.assertRaises(TypeError, lambda: Pusher.from_url(4)) + self.assertRaises(Exception, lambda: Pusher.from_url(u'httpsahsutaeh')) - conf = Config.from_url(u'http://foo:bar@host/apps/4') + conf = Pusher.from_url(u'http://foo:bar@host/apps/4') self.assertEqual(conf.ssl, False) self.assertEqual(conf.key, u'foo') self.assertEqual(conf.secret, u'bar') self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - conf = Config.from_url(u'https://foo:bar@host/apps/4') + conf = Pusher.from_url(u'https://foo:bar@host/apps/4') self.assertEqual(conf.ssl, True) self.assertEqual(conf.key, u'foo') self.assertEqual(conf.secret, u'bar') self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - def test_initialize_from_env(self): - with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): - conf = Config.from_env() - self.assertEqual(conf.ssl, True) - self.assertEqual(conf.key, u'plah') - self.assertEqual(conf.secret, u'bob') - self.assertEqual(conf.host, u'somehost') - self.assertEqual(conf.app_id, u'42') - - with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): - conf = Config.from_env('PUSHER_DSN') - self.assertEqual(conf.ssl, True) - self.assertEqual(conf.key, u'plah') - self.assertEqual(conf.secret, u'bob') - self.assertEqual(conf.host, u'somehost') - self.assertEqual(conf.app_id, u'42') - - def test_authenticate_subscription_types(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - self.assertRaises(TypeError, lambda: conf.authenticate_subscription(b'plah', u'34554')) - self.assertRaises(TypeError, lambda: conf.authenticate_subscription(u'plah', b'324435')) - self.assertRaises(ValueError, lambda: conf.authenticate_subscription(u'::', u'345345')) - - def test_authenticate_subscription_for_private_channels(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - expected = { - u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b" - } - - self.assertEqual(conf.authenticate_subscription(u'private-channel', u'34523'), expected) - - def test_authenticate_subscription_for_presence_channels(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - custom_data = { - u'user_id': u'fred', - u'user_info': { - u'key': u'value' - } - } - - expected = { - u'auth': u"foo:fbbc6d8acc85fc807bba060e2df45aba33deb8ad44cbee1633675b3ce73f4817", - u'channel_data': u"{\"user_id\":\"fred\",\"user_info\":{\"key\":\"value\"}}" - } - - with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: - actual = conf.authenticate_subscription(u'presence-channel', u'34543245', custom_data) - - self.assertEqual(actual, expected) - dumps_mock.assert_called_once_with(custom_data) - - def test_validate_webhook_success_case(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(conf.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time', return_value=1200): - self.assertEqual(conf.validate_webhook(conf.key, signature, body), {u'time_ms': 1000000}) - - def test_validate_webhook_bad_types(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - conf.validate_webhook(u'key', u'signature', u'body') - - # These things are meant to be human readable, so enforcing being text is - # sensible. - - with mock.patch('time.time') as time_mock: - self.assertRaises(TypeError, lambda: conf.validate_webhook(4, u'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(b'test', u'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', 4, u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', b'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', u'signature', 4)) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', u'signature', b'body')) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_key(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'some body' - signature = six.text_type(hmac.new(conf.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time') as time_mock: - self.assertEqual(conf.validate_webhook(u'badkey', signature, body), None) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_signature(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'some body' - signature = u'some signature' - - with mock.patch('time.time') as time_mock: - self.assertEqual(conf.validate_webhook(conf.key, signature, body), None) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_time(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(conf.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time', return_value=1301): - self.assertEqual(conf.validate_webhook(conf.key, signature, body), None) - if __name__ == '__main__': unittest.main() diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 8217a8c..09f4436 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -2,10 +2,14 @@ from __future__ import print_function, absolute_import, division +import os +import six +import hmac +import hashlib import unittest -from pusher import Config, Pusher -from pusher.util import GET +from pusher import Pusher +from pusher.http import GET try: import unittest.mock as mock @@ -14,7 +18,35 @@ class TestPusher(unittest.TestCase): def setUp(self): - self.pusher = Pusher(config=Config.from_url(u'http://key:secret@somehost/apps/4')) + self.pusher = Pusher.from_url(u'http://key:secret@somehost/apps/4') + + def test_app_id_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(key=u'key', secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret')) + + def test_key_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', secret=u'secret')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret')) + + def test_secret_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4)) + + def test_initialize_from_env(self): + with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): + pusher = Pusher.from_env() + self.assertEqual(pusher.ssl, True) + self.assertEqual(pusher.key, u'plah') + self.assertEqual(pusher.secret, u'bob') + self.assertEqual(pusher.host, u'somehost') + self.assertEqual(pusher.app_id, u'42') + + with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): + pusher = Pusher.from_env('PUSHER_DSN') + self.assertEqual(pusher.ssl, True) + self.assertEqual(pusher.key, u'plah') + self.assertEqual(pusher.secret, u'bob') + self.assertEqual(pusher.host, u'somehost') + self.assertEqual(pusher.app_id, u'42') def test_trigger_with_channels_list_success_case(self): json_dumped = u'{"message": "hello world"}' @@ -56,7 +88,99 @@ def test_trigger_disallow_non_string_or_list_channels(self): def test_trigger_disallow_invalid_channels(self): self.assertRaises(ValueError, lambda: - self.pusher.trigger.make_request([u'some_channel!'], u'some_event', {u'message': u'hello world'})) + self.pusher.trigger.make_request([u'so/me_channel!'], u'some_event', {u'message': u'hello world'})) + + def test_authenticate_types(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + self.assertRaises(TypeError, lambda: pusher.authenticate(2423, u'34554')) + self.assertRaises(TypeError, lambda: pusher.authenticate(u'plah', 234234)) + self.assertRaises(ValueError, lambda: pusher.authenticate(u'::', u'345345')) + + def test_authenticate_for_private_channels(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + expected = { + u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b" + } + + self.assertEqual(pusher.authenticate(u'private-channel', u'34523'), expected) + + def test_authenticate_for_presence_channels(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + custom_data = { + u'user_id': u'fred', + u'user_info': { + u'key': u'value' + } + } + + expected = { + u'auth': u"foo:fbbc6d8acc85fc807bba060e2df45aba33deb8ad44cbee1633675b3ce73f4817", + u'channel_data': u"{\"user_id\":\"fred\",\"user_info\":{\"key\":\"value\"}}" + } + + with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: + actual = pusher.authenticate(u'presence-channel', u'34543245', custom_data) + + self.assertEqual(actual, expected) + dumps_mock.assert_called_once_with(custom_data) + + def test_validate_webhook_success_case(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'{"time_ms": 1000000}' + signature = six.text_type(hmac.new(pusher.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time', return_value=1200): + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), {u'time_ms': 1000000}) + + def test_validate_webhook_bad_types(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + pusher.validate_webhook(u'key', u'signature', u'body') + + # These things are meant to be human readable, so enforcing being text is + # sensible. + + with mock.patch('time.time') as time_mock: + self.assertRaises(TypeError, lambda: pusher.validate_webhook(4, u'signature', u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', 4, u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', 4)) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_key(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'some body' + signature = six.text_type(hmac.new(pusher.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time') as time_mock: + self.assertEqual(pusher.validate_webhook(u'badkey', signature, body), None) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_signature(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'some body' + signature = u'some signature' + + with mock.patch('time.time') as time_mock: + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), None) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_time(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'{"time_ms": 1000000}' + signature = six.text_type(hmac.new(pusher.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time', return_value=1301): + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), None) def test_channels_info_default_success_case(self): request = self.pusher.channels_info.make_request() diff --git a/pusher_tests/test_request.py b/pusher_tests/test_request.py index e468870..21b1578 100644 --- a/pusher_tests/test_request.py +++ b/pusher_tests/test_request.py @@ -4,8 +4,8 @@ import unittest -from pusher import Config -from pusher.request import Request +from pusher import Pusher +from pusher.http import Request try: import unittest.mock as mock @@ -14,7 +14,7 @@ class TestRequest(unittest.TestCase): def test_get_signature_generation(self): - conf = Config.from_url(u'http://key:secret@somehost/apps/4') + conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') expected = { u'auth_key': u'key', @@ -30,7 +30,7 @@ def test_get_signature_generation(self): self.assertEqual(req.query_params, expected) def test_post_signature_generation(self): - conf = Config.from_url(u'http://key:secret@somehost/apps/4') + conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') expected = { u'auth_key': u'key', diff --git a/pusher_tests/test_requests_adapter.py b/pusher_tests/test_requests_adapter.py new file mode 100644 index 0000000..cbef43b --- /dev/null +++ b/pusher_tests/test_requests_adapter.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function, absolute_import, division + +from pusher import Pusher +import unittest +import httpretty + +class TestRequestsBackend(unittest.TestCase): + + def setUp(self): + self.pusher = Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4') + + @httpretty.activate + def test_trigger_requests_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = self.pusher.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/pusher_tests/test_urlfetch_adapter.py b/pusher_tests/test_urlfetch_adapter.py new file mode 100644 index 0000000..9ada60e --- /dev/null +++ b/pusher_tests/test_urlfetch_adapter.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function, absolute_import, division + +import pusher +import httpretty +import sys + +if (sys.version_info < (2,7)): + import unittest2 as unittest +else: + import unittest + +@unittest.skipIf(sys.version_info >= (3,), "skip") +class TestURLFetchBackend(unittest.TestCase): + + def setUp(self): + import pusher.urlfetch + self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', + backend=pusher.urlfetch.URLFetchBackend) + + @httpretty.activate + def test_trigger_urlfetch_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/setup.py b/setup.py index ba5e4a4..1f8248d 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- from setuptools import setup setup( - name='pusher-rest', - version='0.1.0', + name='pusher', + version='1.0.0', description='A Python library to interract with the Pusher API', - url='http://github.com/pusher/pusher-rest-python', + url='https://github.com/pusher/pusher-http-python', author='Pusher', author_email='support@pusher.com', classifiers=[ @@ -17,15 +17,17 @@ keywords='pusher rest realtime websockets service', license='MIT', - packages=['pusher'], + packages=[ + 'pusher' + ], - install_requires=['six'], - tests_require=['nose', 'mock'], + install_requires=['six', 'requests>=2.3.0'], + tests_require=['nose', 'mock', 'HTTPretty'], extras_require={ 'aiohttp': ["aiohttp>=0.9.0"], - 'requests': ['requests>=2.3.0'], 'tornado': ['tornado>=4.0.0'], + 'urlfetch': ['urlfetch>=1.0'] }, test_suite='pusher_tests', diff --git a/trigger_aiohttp.py b/trigger_aiohttp.py new file mode 100644 index 0000000..be2cb3c --- /dev/null +++ b/trigger_aiohttp.py @@ -0,0 +1,14 @@ +import pusher +import pusher.aiohttp +import asyncio + +def main(): + client = pusher.Pusher.from_env( + backend=pusher.aiohttp.AsyncIOBackend, + timeout=50 + ) + print("before trigger") + response = yield from client.trigger("hello", "world", dict(foo='bar')) + print(response) + +asyncio.get_event_loop().run_until_complete(main()) diff --git a/trigger_tornado.py b/trigger_tornado.py new file mode 100644 index 0000000..d5af180 --- /dev/null +++ b/trigger_tornado.py @@ -0,0 +1,19 @@ +import pusher +import pusher.tornado +import tornado.ioloop + +ioloop = tornado.ioloop.IOLoop.instance() + +def show_response(response): + print(response.result()) + ioloop.stop() + +client = pusher.Pusher.from_env( + backend=pusher.tornado.TornadoBackend, + timeout=50 + ) +response = client.trigger("hello", "world", dict(foo='bar')) +response.add_done_callback(show_response) +print("Before start") +ioloop.start() +print("After start")