Skip to content

Commit a89a6c9

Browse files
authored
Merge pull request #77 from pusher/native-push
Basic native push
2 parents 46fc879 + b5e5d52 commit a89a6c9

File tree

6 files changed

+420
-68
lines changed

6 files changed

+420
-68
lines changed

README.md

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,22 +150,66 @@ pusher.trigger_batch([
150150
])
151151
```
152152

153-
#### Event Buffer
153+
## Push Notifications (BETA)
154154

155-
Version 1.0.0 of the library introduced support for event buffering. The purpose of this functionality is to ensure that events that are triggered during whilst a client is offline for a short period of time will still be delivered upon reconnection.
155+
Pusher now allows sending native notifications to iOS and Android devices. Check out the [documentation](https://pusher.com/docs/push_notifications) for information on how to set up push notifications on Android and iOS. There is no additional setup required to use it with this library. It works out of the box wit the same Pusher instance. All you need are the same pusher credentials.
156156

157-
**Note: this requires your Pusher application to be on a cluster that has the Event Buffer capability.**
157+
### Sending native pushes
158158

159-
As part of this the trigger function now returns a set of event_id values for each event triggered on a channel. These can then be used by the client to tell the Pusher service the last event it has received. If additional events have been triggered after that event ID the service has the opportunity to provide the client with those IDs.
159+
The native notifications API is hosted at `nativepushclient-cluster1.pusher.com` and only accepts https requests.
160160

161-
##### Example
161+
You can send pushes by using the `notify` method, either globally or on the instance. The method takes two parameters:
162+
163+
- `interests`: An Array of strings which represents the interests your devices are subscribed to. These are akin to channels in the DDN with less of an epehemeral nature. Note that currently, you can only send to _one_ interest.
164+
- `data`: The content of the notification represented by a Hash. You must supply either the `gcm` or `apns` key. For a detailed list of the acceptable keys, take a look at the [docs](https://pusher.com/docs/push_notifications#payload).
165+
166+
Example:
162167

163168
```python
164-
events = pusher.trigger([u'a_channel', u'another_channel'], u'an_event', {u'some': u'data'}, "1234.12")
169+
data = {
170+
'apns': {
171+
'priority': 5,
172+
'aps': {
173+
'alert': {
174+
'body': 'tada'
175+
}
176+
}
177+
}
178+
}
165179

166-
#=> {'event_ids': {'another_channel': 'eudhq17zrhfbwc', 'a_channel': 'eudhq17zrhfbtn'}}
180+
pusher.notify(["my-favourite-interest"], data)
167181
```
168182

183+
### Errors
184+
185+
Push notification requests, once submitted to the service are executed asynchronously. To make reporting errors easier, you can supply a `webhook_url` field in the body of the request. This will be used by the service to send a webhook to the supplied URL if there are errors.
186+
187+
You may also supply a `webhook_level` field in the body, which can either be INFO or DEBUG. It defaults to INFO - where INFO only reports customer facing errors, while DEBUG reports all errors.
188+
189+
For example:
190+
191+
```python
192+
data = {
193+
"apns": {
194+
"aps": {
195+
"alert": {
196+
"body": "hello"
197+
}
198+
}
199+
},
200+
'gcm': {
201+
'notification': {
202+
"title": "hello",
203+
"icon": "icon"
204+
}
205+
},
206+
"webhook_url": "http://yolo.com",
207+
"webhook_level": "INFO"
208+
}
209+
```
210+
211+
**NOTE:** This is currently a BETA feature and there might be minor bugs and issues. Changes to the API will be kept to a minimum, but changes are expected. If you come across any bugs or issues, please do get in touch via [support](support@pusher.com) or create an issue here.
212+
169213
Querying Application State
170214
-----------------
171215

pusher/config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from .util import ensure_text, app_id_re
4+
5+
import six
6+
7+
class Config(object):
8+
9+
def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None,
10+
json_encoder=None, json_decoder=None, backend=None, **backend_options):
11+
if backend is None:
12+
from .requests import RequestsBackend
13+
backend = RequestsBackend
14+
15+
self._app_id = ensure_text(app_id, "app_id")
16+
if not app_id_re.match(self._app_id):
17+
raise ValueError("Invalid app id")
18+
19+
self._key = ensure_text(key, "key")
20+
self._secret = ensure_text(secret, "secret")
21+
22+
if not isinstance(ssl, bool):
23+
raise TypeError("SSL should be a boolean")
24+
self._ssl = ssl
25+
26+
if port and not isinstance(port, six.integer_types):
27+
raise TypeError("port should be an integer")
28+
self._port = port or (443 if ssl else 80)
29+
30+
if not isinstance(timeout, six.integer_types):
31+
raise TypeError("timeout should be an integer")
32+
self._timeout = timeout
33+
self._json_encoder = json_encoder
34+
self._json_decoder = json_decoder
35+
36+
self.http = backend(self, **backend_options)
37+
38+
@property
39+
def app_id(self):
40+
return self._app_id
41+
42+
@property
43+
def key(self):
44+
return self._key
45+
46+
@property
47+
def secret(self):
48+
return self._secret
49+
50+
@property
51+
def host(self):
52+
return self._host
53+
54+
@property
55+
def port(self):
56+
return self._port
57+
58+
@property
59+
def timeout(self):
60+
return self._timeout
61+
62+
@property
63+
def ssl(self):
64+
return self._ssl
65+
66+
@property
67+
def scheme(self):
68+
return 'https' if self.ssl else 'http'

pusher/http.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def make_query_string(params):
4343
def process_response(status, body):
4444
if status == 200:
4545
return json.loads(body)
46+
if status == 202:
47+
return True
4648
elif status == 400:
4749
raise PusherBadRequest(body)
4850
elif status == 401:

pusher/notification_client.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from .config import Config
2+
from .http import POST, Request, request_method
3+
from .util import ensure_text
4+
5+
DEFAULT_HOST = "yolo.ngrok.io"
6+
RESTRICTED_GCM_KEYS = ['to', 'registration_ids']
7+
API_PREFIX = 'customer_api'
8+
API_VERSION = 'v1'
9+
GCM_TTL = 241920
10+
WEBHOOK_LEVELS = ['INFO', 'DEBUG', '']
11+
12+
class NotificationClient(Config):
13+
14+
def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None,
15+
json_encoder=None, json_decoder=None, backend=None, **backend_options):
16+
17+
super(NotificationClient, self).__init__(
18+
app_id, key, secret, ssl,
19+
host, port, timeout, cluster,
20+
json_encoder, json_decoder, backend,
21+
**backend_options)
22+
23+
if host:
24+
self._host = ensure_text(host, "host")
25+
else:
26+
self._host = DEFAULT_HOST
27+
28+
29+
@request_method
30+
def notify(self, interests, notification):
31+
if not isinstance(interests, list) and not isinstance(interests, set):
32+
raise TypeError("Interests must be a list or a set")
33+
34+
if len(interests) is not 1:
35+
raise ValueError("Currently sending to more than one interest is unsupported")
36+
37+
if not isinstance(notification, dict):
38+
raise TypeError("Notification must be a dictionary")
39+
40+
params = {
41+
'interests': interests,
42+
}
43+
params.update(notification)
44+
self.validate_notification(params)
45+
path = "/%s/%s/apps/%s/notifications" % (API_PREFIX, API_VERSION, self.app_id)
46+
return Request(self, POST, path, params)
47+
48+
def validate_notification(self, notification):
49+
gcm_payload = notification.get('gcm')
50+
51+
if not gcm_payload and not notification.get('apns') :
52+
raise ValueError("Notification must have fields APNS or GCM")
53+
54+
if gcm_payload:
55+
for restricted_key in RESTRICTED_GCM_KEYS:
56+
gcm_payload.pop(restricted_key, None)
57+
58+
ttl = gcm_payload.get('time_to_live')
59+
if ttl:
60+
if not isinstance(ttl, int):
61+
raise ValueError("GCM time_to_live must be an int")
62+
63+
if not (0 <= ttl <= GCM_TTL):
64+
raise ValueError("GCM time_to_live must be between 0 and 241920 (4 weeks)")
65+
66+
gcm_payload_notification = gcm_payload.get('notification')
67+
if gcm_payload_notification:
68+
title = gcm_payload_notification.get('title')
69+
icon = gcm_payload_notification.get('icon')
70+
if not isinstance(title, str):
71+
raise ValueError("GCM notification title is required must be a string")
72+
73+
if not isinstance(icon, str):
74+
raise ValueError("GCM notification icon is required must be a string")
75+
76+
if len(title) is 0:
77+
raise ValueError("GCM notification title must not be empty")
78+
79+
if len(icon) is 0:
80+
raise ValueError("GCM notification icon must not be empty")
81+
82+
webhook_url = notification.get('webhook_url')
83+
webhook_level = notification.get('webhook_level')
84+
85+
if webhook_level:
86+
if not webhook_url:
87+
raise ValueError("webhook_level cannot be used without a webhook_url")
88+
89+
if not webhook_level in WEBHOOK_LEVELS:
90+
raise ValueError("webhook_level must be either INFO or DEBUG. Blank will default to INFO")

pusher/pusher.py

Lines changed: 22 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
division)
55
from pusher.http import GET, POST, Request, request_method
66
from pusher.signature import sign, verify
7-
from pusher.util import ensure_text, validate_channel, validate_socket_id, app_id_re, pusher_url_re, channel_name_re
7+
from pusher.util import ensure_text, validate_channel, validate_socket_id, pusher_url_re, channel_name_re
8+
from pusher.config import Config
9+
from pusher.notification_client import NotificationClient
810

911
import collections
1012
import hashlib
@@ -17,7 +19,7 @@
1719
def join_attributes(attributes):
1820
return six.text_type(',').join(attributes)
1921

20-
class Pusher(object):
22+
class Pusher(Config):
2123
"""Client for the Pusher HTTP API.
2224
2325
This client supports various backend adapters to support various http
@@ -36,41 +38,25 @@ class Pusher(object):
3638
:param backend_options: additional backend
3739
"""
3840
def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None,
39-
json_encoder=None, json_decoder=None, backend=None, **backend_options):
40-
41-
if backend is None:
42-
from pusher.requests import RequestsBackend
43-
backend = RequestsBackend
44-
45-
self._app_id = ensure_text(app_id, "app_id")
46-
if not app_id_re.match(self._app_id):
47-
raise ValueError("Invalid app id")
48-
49-
self._key = ensure_text(key, "key")
50-
self._secret = ensure_text(secret, "secret")
51-
52-
if not isinstance(ssl, bool):
53-
raise TypeError("SSL should be a boolean")
54-
self._ssl = ssl
55-
41+
json_encoder=None, json_decoder=None, backend=None, notification_host=None,
42+
notification_ssl=True, **backend_options):
43+
super(Pusher, self).__init__(
44+
app_id, key, secret, ssl,
45+
host, port, timeout, cluster,
46+
json_encoder, json_decoder, backend,
47+
**backend_options)
5648
if host:
5749
self._host = ensure_text(host, "host")
5850
elif cluster:
5951
self._host = six.text_type("api-%s.pusher.com") % ensure_text(cluster, "cluster")
6052
else:
6153
self._host = six.text_type("api.pusherapp.com")
6254

63-
if port and not isinstance(port, six.integer_types):
64-
raise TypeError("port should be an integer")
65-
self._port = port or (443 if ssl else 80)
66-
67-
if not isinstance(timeout, six.integer_types):
68-
raise TypeError("timeout should be an integer")
69-
self._timeout = timeout
70-
self._json_encoder = json_encoder
71-
self._json_decoder = json_decoder
72-
73-
self.http = backend(self, **backend_options)
55+
self._notification_client = NotificationClient(
56+
app_id, key, secret, notification_ssl,
57+
notification_host, port, timeout, cluster,
58+
json_encoder, json_decoder, backend,
59+
**backend_options)
7460

7561
@classmethod
7662
def from_url(cls, url, **options):
@@ -116,7 +102,7 @@ def from_env(cls, env='PUSHER_URL', **options):
116102
val = os.environ.get(env)
117103
if not val:
118104
raise Exception("Environment variable %s not found" % env)
119-
105+
120106
return cls.from_url(val, **options)
121107

122108
@request_method
@@ -126,7 +112,7 @@ def trigger(self, channels, event_name, data, socket_id=None):
126112
127113
http://pusher.com/docs/rest_api#method-post-event
128114
'''
129-
115+
130116
if isinstance(channels, six.string_types):
131117
channels = [channels]
132118

@@ -280,36 +266,11 @@ def validate_webhook(self, key, signature, body):
280266
return body_data
281267

282268
@property
283-
def app_id(self):
284-
return self._app_id
285-
286-
@property
287-
def key(self):
288-
return self._key
289-
290-
@property
291-
def secret(self):
292-
return self._secret
293-
294-
@property
295-
def host(self):
296-
return self._host
297-
298-
@property
299-
def port(self):
300-
return self._port
301-
302-
@property
303-
def timeout(self):
304-
return self._timeout
269+
def notification_client(self):
270+
return self._notification_client
305271

306-
@property
307-
def ssl(self):
308-
return self._ssl
309-
310-
@property
311-
def scheme(self):
312-
return 'https' if self.ssl else 'http'
272+
def notify(self, interest, notification):
273+
self._notification_client.notify(interest, notification)
313274

314275
def _data_to_string(self, data):
315276
if isinstance(data, six.string_types):

0 commit comments

Comments
 (0)