Skip to content

Commit 151c758

Browse files
committed
Merge pull request #90 from geeknam/topic_messaging
Added Topic Messaging support
2 parents cd9a5d9 + 3dcc42b commit 151c758

File tree

3 files changed

+149
-7
lines changed

3 files changed

+149
-7
lines changed

README.rst

+6
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Features
3636
* Proxy support
3737
* Easily handle errors
3838
* Uses `requests` from version > 0.2
39+
* Topic Messaging `Reference <https://developers.google.com/cloud-messaging/topic-messaging>`__
3940

4041
Usage
4142
------------
@@ -65,6 +66,11 @@ Basic
6566
collapse_key='uptoyou', delay_while_idle=True, time_to_live=3600
6667
)
6768
69+
# Topic Messaging
70+
topic = 'foo'
71+
gcm.send_topic_message(topic=topic, data=data)
72+
73+
6874
Error handling
6975

7076
.. code-block:: python

gcm/gcm.py

+85-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import random
66
from sys import version_info
77
import logging
8+
import re
89

910
GCM_URL = 'https://gcm-http.googleapis.com/gcm/send'
1011

@@ -32,6 +33,11 @@ class GCMTooManyRegIdsException(GCMException):
3233
class GCMInvalidTtlException(GCMException):
3334
pass
3435

36+
37+
class GCMTopicMessageException(GCMException):
38+
pass
39+
40+
3541
# Exceptions from Google responses
3642

3743

@@ -59,6 +65,10 @@ class GCMUnavailableException(GCMException):
5965
pass
6066

6167

68+
class GCMInvalidInputException(GCMException):
69+
pass
70+
71+
6272
# TODO: Refactor this to be more human-readable
6373
# TODO: Use OrderedDict for the result type to be able to preserve the order of the messages returned by GCM server
6474
def group_response(response, registration_ids, key):
@@ -104,6 +114,8 @@ class Payload(object):
104114
# TTL in seconds
105115
GCM_TTL = 2419200
106116

117+
topicPattern = re.compile('/topics/[a-zA-Z0-9-_.~%]+')
118+
107119
def __init__(self, **kwargs):
108120
self.validate(kwargs)
109121
self.__dict__.update(**kwargs)
@@ -121,7 +133,16 @@ def validate(self, options):
121133
def validate_time_to_live(self, value):
122134
if not (0 <= value <= self.GCM_TTL):
123135
raise GCMInvalidTtlException("Invalid time to live value")
124-
136+
137+
def validate_registration_ids(self, registration_ids):
138+
139+
if len(registration_ids) > 1000:
140+
raise GCMTooManyRegIdsException("Exceded number of registration_ids")
141+
142+
def validate_to(self, value):
143+
if not re.match(Payload.topicPattern, value):
144+
raise GCMInvalidInputException("Invalid topic name: {0}! Does not match the {1} pattern".format(value, Payload.topicPattern))
145+
125146
@property
126147
def body(self):
127148
raise NotImplementedError
@@ -221,6 +242,16 @@ def construct_payload(self, **kwargs):
221242
is_json = kwargs.pop('is_json', True)
222243

223244
if is_json:
245+
if 'topic' not in kwargs and 'registration_ids' not in kwargs:
246+
raise GCMMissingRegistrationException("Missing registration_ids or topic")
247+
elif 'topic' in kwargs and 'registration_ids' in kwargs :
248+
raise GCMInvalidInputException("Invalid parameters! Can't have both 'registration_ids' and 'to' as input parameters")
249+
250+
if 'topic' in kwargs:
251+
kwargs['to'] = '/topics/{}'.format(kwargs.pop('topic'))
252+
elif 'registration_ids' not in kwargs:
253+
raise GCMMissingRegistrationException("Missing registration_ids")
254+
224255
payload = JsonPayload(**kwargs).body
225256
else:
226257
payload = PlaintextPayload(**kwargs).body
@@ -343,6 +374,12 @@ def handle_json_response(self, response, registration_ids):
343374

344375
return info
345376

377+
def handle_topic_response(self, response):
378+
error = response.get('error')
379+
if error:
380+
raise GCMTopicMessageException(error)
381+
return response['message_id']
382+
346383
def extract_unsent_reg_ids(self, info):
347384
if 'errors' in info and 'Unavailable' in info['errors']:
348385
return info['errors']['Unavailable']
@@ -354,11 +391,14 @@ def plaintext_request(self, **kwargs):
354391
355392
:return dict of response body from Google including multicast_id, success, failure, canonical_ids, etc
356393
"""
394+
if 'registration_id' not in kwargs:
395+
raise GCMMissingRegistrationException("Missing registration_id")
396+
elif not kwargs['registration_id']:
397+
raise GCMMissingRegistrationException("Empty registration_id")
357398

358399
kwargs['is_json'] = False
359400
retries = kwargs.pop('retries', 5)
360401
payload = self.construct_payload(**kwargs)
361-
362402
backoff = self.BACKOFF_INITIAL_DELAY
363403
info = None
364404
has_error = False
@@ -392,10 +432,14 @@ def json_request(self, **kwargs):
392432
"""
393433
Makes a JSON request to GCM servers
394434
395-
:param registration_ids: list of the registration ids
396-
:param data: dict mapping of key-value pairs of messages
435+
:param kwargs: dict mapping of key-value pairs of parameters
397436
:return dict of response body from Google including multicast_id, success, failure, canonical_ids, etc
398437
"""
438+
if 'registration_ids' not in kwargs:
439+
raise GCMMissingRegistrationException("Missing registration_ids")
440+
elif not kwargs['registration_ids']:
441+
raise GCMMissingRegistrationException("Empty registration_ids")
442+
399443
args = dict(**kwargs)
400444

401445
retries = args.pop('retries', 5)
@@ -440,3 +484,40 @@ def json_request(self, **kwargs):
440484
raise IOError("Could not make request after %d attempts" % retries)
441485

442486
return info
487+
488+
def send_topic_message(self, **kwargs):
489+
"""
490+
Publish Topic Messaging to GCM servers
491+
Ref: https://developers.google.com/cloud-messaging/topic-messaging
492+
493+
:param kwargs: dict mapping of key-value pairs of parameters
494+
:return message_id
495+
:raises GCMInvalidInputException: if the topic is empty
496+
"""
497+
498+
if 'topic' not in kwargs:
499+
raise GCMInvalidInputException("Topic name missing!")
500+
elif not kwargs['topic']:
501+
raise GCMInvalidInputException("Topic name cannot be empty!")
502+
503+
retries = kwargs.pop('retries', 5)
504+
payload = self.construct_payload(**kwargs)
505+
backoff = self.BACKOFF_INITIAL_DELAY
506+
507+
for attempt in range(retries):
508+
try:
509+
response = self.make_request(payload, is_json=True)
510+
return self.handle_topic_response(response)
511+
except GCMUnavailableException:
512+
sleep_time = backoff / 2 + random.randrange(backoff)
513+
time.sleep(float(sleep_time) / 1000)
514+
if 2 * backoff < self.MAX_BACKOFF_DELAY:
515+
backoff *= 2
516+
else:
517+
raise IOError("Could not make request after %d attempts" % retries)
518+
519+
def send_device_group_message(self, **kwargs):
520+
raise NotImplementedError
521+
522+
def send_downstream_message(self, **kwargs):
523+
return self.json_request(**kwargs)

gcm/test.py

+58-3
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,42 @@ def test_plaintext_payload(self):
106106
self.assertIn('data.param1', result)
107107
self.assertIn('data.param2', result)
108108

109+
def test_topic_payload(self):
110+
topic = 'foo'
111+
json_payload = self.gcm.construct_payload(topic=topic,
112+
data=self.data)
113+
payload = json.loads(json_payload)
114+
115+
self.assertEqual(payload['data'], self.data)
116+
self.assertEqual(payload.get('to'), '/topics/foo')
117+
118+
def test_invalid_registration_ids_and_topic(self):
119+
with self.assertRaises(GCMMissingRegistrationException):
120+
self.gcm.construct_payload()
121+
122+
with self.assertRaises(GCMInvalidInputException):
123+
reg_ids = ['12', '145', '56']
124+
topic = 'foo'
125+
self.gcm.construct_payload(registration_ids=reg_ids,
126+
topic=topic)
127+
128+
def test_limit_reg_ids(self):
129+
reg_ids = range(1003)
130+
self.assertTrue(len(reg_ids) > 1000)
131+
with self.assertRaises(GCMTooManyRegIdsException):
132+
self.gcm.json_request(registration_ids=reg_ids, data=self.data)
133+
134+
def test_missing_reg_id(self):
135+
with self.assertRaises(GCMMissingRegistrationException):
136+
self.gcm.json_request(registration_ids=[], data=self.data)
137+
138+
with self.assertRaises(GCMMissingRegistrationException):
139+
self.gcm.plaintext_request(registration_id=None, data=self.data)
140+
141+
def test_empty_topic(self):
142+
with self.assertRaises(GCMInvalidInputException):
143+
self.gcm.send_topic_message(topic='', data=self.data)
144+
109145
def test_invalid_ttl(self):
110146
with self.assertRaises(GCMInvalidTtlException):
111147
self.gcm.construct_payload(
@@ -192,6 +228,15 @@ def test_handle_plaintext_response(self):
192228
res = self.gcm.handle_plaintext_response(response)
193229
self.assertEqual(res, '3456')
194230

231+
def test_handle_topic_response(self):
232+
response = {'error': 'TopicsMessageRateExceeded'}
233+
with self.assertRaises(GCMTopicMessageException):
234+
self.gcm.handle_topic_response(response)
235+
236+
response = {'message_id': '10'}
237+
res = self.gcm.handle_topic_response(response)
238+
self.assertEqual('10', res)
239+
195240
@patch('requests.post')
196241
def test_make_request_header(self, mock_request):
197242
""" Test plaintext make_request. """
@@ -310,12 +355,22 @@ def test_retry_json_request_fail(self):
310355
self.assertIn('Unavailable', res['errors'])
311356
self.assertEqual(res['errors']['Unavailable'][0], '1')
312357

313-
def test_retry_json_request_unavailable(self):
314-
returns = [GCMUnavailableException(), GCMUnavailableException(), GCMUnavailableException()]
358+
def test_retry_topic_request_ok(self):
359+
message_id = '123456789'
360+
returns = [GCMUnavailableException(), GCMUnavailableException(), {'message_id': message_id}]
315361

316362
self.gcm.make_request = MagicMock(side_effect=create_side_effect(returns))
363+
res = self.gcm.send_topic_message(topic='foo', data=self.data)
364+
365+
self.assertEqual(res, message_id)
366+
self.assertEqual(self.gcm.make_request.call_count, 3)
367+
368+
def test_retry_topic_request_fail(self):
369+
returns = [GCMUnavailableException(), GCMUnavailableException(), GCMUnavailableException()]
370+
self.gcm.make_request = MagicMock(side_effect=create_side_effect(returns))
371+
317372
with self.assertRaises(IOError):
318-
self.gcm.json_request(registration_ids=['1', '2'], data=self.data, retries=2)
373+
self.gcm.send_topic_message(topic='foo', data=self.data, retries=2)
319374

320375
self.assertEqual(self.gcm.make_request.call_count, 2)
321376

0 commit comments

Comments
 (0)