Skip to content

Commit f4f3fda

Browse files
authored
Merge pull request #43 from pusher/native-push
Native push notifications
2 parents ae07696 + 7b750a9 commit f4f3fda

File tree

6 files changed

+326
-13
lines changed

6 files changed

+326
-13
lines changed

lib/config.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,6 @@ var Token = require('./token');
33
function Config(options) {
44
options = options || {};
55

6-
if (options.host) {
7-
this.host = options.host
8-
}
9-
else if (options.cluster) {
10-
this.host = "api-"+options.cluster+".pusher.com";
11-
}
12-
else {
13-
this.host = "api.pusherapp.com";
14-
}
15-
166
this.scheme = options.scheme || (options.encrypted ? "https" : "http");
177
this.port = options.port;
188

@@ -25,7 +15,7 @@ function Config(options) {
2515
}
2616

2717
Config.prototype.prefixPath = function(subPath) {
28-
return "/apps/" + this.appId + subPath;
18+
throw("NotImplementedError: #prefixPath should be implemented by subclasses");
2919
};
3020

3121
Config.prototype.getBaseURL = function(subPath, queryString) {

lib/notification_client.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
var Config = require('./config');
2+
var requests = require('./requests');
3+
var util = require('./util');
4+
var NotificationConfig = require('./notification_config');
5+
6+
var RESTRICTED_GCM_KEYS = ['to', 'registration_ids'];
7+
var GCM_TTL = 241920;
8+
var WEBHOOK_LEVELS = {
9+
"INFO": true,
10+
"DEBUG": true,
11+
"": true
12+
}
13+
14+
function NotificationClient(options){
15+
this.config = new NotificationConfig(options);
16+
}
17+
18+
NotificationClient.prototype.notify = function(interests, notification, callback) {
19+
if (!Array.isArray(interests)) {
20+
throw new Error("Interests must be an array");
21+
}
22+
23+
if (interests.length != 1) {
24+
throw new Error("Currently sending to more than one interest is unsupported")
25+
}
26+
27+
28+
var body = util.mergeObjects({interests: interests}, notification);
29+
this.validateNotification(body);
30+
requests.send(this.config, {
31+
method: "POST",
32+
body: body,
33+
path: "/notification"
34+
}, callback);
35+
}
36+
37+
NotificationClient.prototype.validateNotification = function(notification) {
38+
var gcmPayload = notification.gcm;
39+
if (!gcmPayload && !notification.apns) {
40+
throw new Error("Notification must have fields APNS or GCM");
41+
}
42+
43+
if (gcmPayload) {
44+
for (var index in RESTRICTED_GCM_KEYS) {
45+
var restrictedKey = RESTRICTED_GCM_KEYS[index];
46+
delete(gcmPayload[restrictedKey]);
47+
}
48+
49+
var ttl = gcmPayload.time_to_live;
50+
if (ttl) {
51+
if (isNaN(ttl)) {
52+
throw new Error("provided ttl must be a number");
53+
}
54+
55+
if (!(ttl > 0 && ttl < GCM_TTL)) {
56+
throw new Error("GCM time_to_live must be between 0 and 241920 (4 weeks)");
57+
}
58+
}
59+
60+
var gcmPayloadNotification = gcmPayload.notification;
61+
if (gcmPayloadNotification) {
62+
var title = gcmPayloadNotification.title;
63+
var icon = gcmPayloadNotification.icon;
64+
var requiredFields = [title, icon];
65+
66+
if (typeof(title) !== 'string' || title.length === 0) {
67+
throw new Error("GCM title must be a string and not empty");
68+
}
69+
70+
if (typeof(icon) !== 'string' || icon.length === 0) {
71+
throw new Error("GCM icon must be a string and not empty");
72+
}
73+
}
74+
}
75+
76+
var webhookURL = notification.webhook_url;
77+
var webhookLevel = notification.webhook_level;
78+
79+
if (webhookLevel) {
80+
if (!webhookURL) throw new Error("webhook_level cannot be used without a webhook_url");
81+
if (!WEBHOOK_LEVELS[webhookLevel]) {
82+
throw new Error("webhook_level must be either INFO or DEBUG. Blank will default to INFO");
83+
}
84+
}
85+
}
86+
87+
module.exports = NotificationClient;

lib/notification_config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
var Config = require('./config');
2+
var util = require('./util');
3+
4+
var DEFAULT_HOST = "nativepushclient-cluster1.pusher.com";
5+
var API_PREFIX = "customer_api";
6+
var API_VERSION = "v1";
7+
8+
function NotificationConfig(options) {
9+
Config.call(this, options);
10+
this.host = options.host || DEFAULT_HOST;
11+
}
12+
13+
util.mergeObjects(NotificationConfig.prototype, Config.prototype);
14+
15+
NotificationConfig.prototype.prefixPath = function(subPath) {
16+
return "/" + API_PREFIX + "/" + API_VERSION + "/apps/" + this.appId + subPath;
17+
};
18+
19+
module.exports = NotificationConfig;

lib/pusher.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ var events = require('./events');
66
var requests = require('./requests');
77
var util = require('./util');
88

9-
var Config = require('./config');
9+
var PusherConfig = require('./pusher_config');
1010
var Token = require('./token');
1111
var WebHook = require('./webhook');
12+
var NotificationClient = require('./notification_client');
1213

1314
var validateChannel = function(channel) {
1415
if (typeof channel !== "string" || channel === "" || channel.match(/[^A-Za-z0-9_\-=@,.;]/)) {
@@ -38,7 +39,9 @@ var validateSocketId = function(socketId) {
3839
* @constructor
3940
* @param {Object} options
4041
* @param {String} [options.host="api.pusherapp.com"] API hostname
42+
* @param {String} [options.notification_host="api.pusherapp.com"] Notification API hostname
4143
* @param {Boolean} [options.encrypted=false] whether to use SSL
44+
* @param {Boolean} [options.notification_encrypted=false] whether to use SSL for notifications
4245
* @param {Integer} [options.port] port, default depends on the scheme
4346
* @param {Integer} options.appId application ID
4447
* @param {String} options.key application key
@@ -48,7 +51,12 @@ var validateSocketId = function(socketId) {
4851
* @param {Boolean} [options.keepAlive] whether requests should use keep-alive
4952
*/
5053
function Pusher(options) {
51-
this.config = new Config(options);
54+
this.config = new PusherConfig(options);
55+
var notificationOptions = util.mergeObjects({}, options, {
56+
host: options.notificationHost || "yolo.ngrok.io",
57+
encrypted: options.notificationEncrypted
58+
});
59+
this.notificationClient = new NotificationClient(notificationOptions);
5260
}
5361

5462
/** Create a Pusher instance using a URL.
@@ -156,6 +164,10 @@ Pusher.prototype.triggerBatch = function(batch, callback) {
156164
events.triggerBatch(this, batch, callback);
157165
}
158166

167+
Pusher.prototype.notify = function() {
168+
this.notificationClient.notify.apply(this.notificationClient, arguments);
169+
}
170+
159171
/** Makes a POST request to Pusher, handles the authentication.
160172
*
161173
* Calls back with three arguments - error, request and response. When request

lib/pusher_config.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
var Config = require('./config');
2+
var util = require('./util');
3+
4+
function PusherConfig(options) {
5+
Config.call(this, options);
6+
7+
if (options.host) {
8+
this.host = options.host
9+
}
10+
else if (options.cluster) {
11+
this.host = "api-"+options.cluster+".pusher.com";
12+
}
13+
else {
14+
this.host = "api.pusherapp.com";
15+
}
16+
}
17+
18+
util.mergeObjects(PusherConfig.prototype, Config.prototype);
19+
20+
PusherConfig.prototype.prefixPath = function(subPath) {
21+
return "/apps/" + this.appId + subPath;
22+
};
23+
24+
module.exports = PusherConfig;
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
var expect = require("expect.js");
2+
var NotificationClient = require("../../../lib/notification_client");
3+
var nock = require('nock');
4+
5+
describe("NativeNotificationClient", function() {
6+
var client;
7+
8+
beforeEach(function(){
9+
client = new NotificationClient({ appId: 1234, key: "f00d", secret: "beef" });
10+
nock.cleanAll();
11+
nock.disableNetConnect();
12+
});
13+
14+
afterEach(function() {
15+
nock.cleanAll();
16+
nock.enableNetConnect();
17+
});
18+
19+
xit("should send in the success case", function(done){
20+
var mock = nock("nativepushclient-cluster1.pusher.com:80")
21+
client.notify(['yolo'],{
22+
'apns': {
23+
'aps': {
24+
'alert':{
25+
'title': 'yolo',
26+
'body': 'woot'
27+
}
28+
}
29+
},
30+
'gcm': {
31+
'notification': {
32+
'title': 'huzzah',
33+
'icon': 'woot'
34+
}
35+
}
36+
37+
}, function(){
38+
expect(mock.isDone()).to.be(true);
39+
done();
40+
});
41+
});
42+
43+
it("should remove restricted GCM keys", function(){
44+
var notification = {
45+
'gcm': {
46+
'to': 'woot',
47+
'registration_ids': ['woot', 'bla'],
48+
'notification': {
49+
'title': 'yipee',
50+
'icon': 'huh'
51+
}
52+
}
53+
};
54+
55+
client.validateNotification(notification);
56+
expect(notification).to.eql({ gcm: { notification: { title: 'yipee', icon: 'huh' }}});
57+
});
58+
59+
it("should validate that either apns or gcm are present", function(){
60+
var notification = {};
61+
expect(function(){
62+
client.validateNotification(notification);
63+
}).to.throwException(/Notification must have fields APNS or GCM/);
64+
});
65+
66+
it("should validate that only one interest is sent to", function(){
67+
expect(function(){
68+
client.notify(['yolo', 'woot'], {});
69+
}).to.throwException(/Currently sending to more than one interest is unsupported/);
70+
})
71+
72+
it("should invalidate certain gcm payloads", function(){
73+
var invalidGcmPayloads = [
74+
{
75+
'gcm': {
76+
'time_to_live': -1,
77+
'notification': {
78+
'title': 'yipee',
79+
'icon': 'huh'
80+
}
81+
},
82+
exception: /GCM time_to_live must be between 0 and 241920 \(4 weeks\)/
83+
},
84+
{
85+
'gcm': {
86+
'time_to_live': 241921,
87+
'notification': {
88+
'title': 'yipee',
89+
'icon': 'huh'
90+
}
91+
},
92+
exception: /GCM time_to_live must be between 0 and 241920 \(4 weeks\)/
93+
},
94+
{
95+
'gcm': {
96+
'notification': {
97+
'title': 'yipee',
98+
}
99+
},
100+
exception: /GCM icon must be a string and not empty/
101+
},
102+
{
103+
'gcm': {
104+
'notification': {
105+
'icon': 'huh'
106+
}
107+
},
108+
exception: /GCM title must be a string and not empty/
109+
},
110+
{
111+
'gcm': {
112+
'notification': {
113+
'title': '',
114+
'icon': 'huh'
115+
}
116+
},
117+
exception: /GCM title must be a string and not empty/
118+
},
119+
{
120+
'gcm': {
121+
'notification': {
122+
'title': 'yipee',
123+
'icon': ''
124+
}
125+
},
126+
exception: /GCM icon must be a string and not empty/
127+
}
128+
]
129+
130+
for (var index in invalidGcmPayloads) {
131+
var invalidPayload = invalidGcmPayloads[index];
132+
expect(function(){
133+
client.notify(['yolo'], invalidPayload);
134+
}).to.throwException(invalidPayload.exception || /%/);
135+
}
136+
});
137+
138+
it("validates webhook config", function(){
139+
invalidWebhookConfig = [
140+
{
141+
'webhook_level': 'DEBUG',
142+
'apns': {
143+
'alert': {
144+
'title': 'yolo',
145+
'body': 'woot'
146+
}
147+
},
148+
'gcm': {
149+
'notification': {
150+
'title': 'yipee',
151+
'icon': 'huh'
152+
}
153+
},
154+
exception: 'webhook_level cannot be used without a webhook_url'
155+
},
156+
{
157+
'webhook_level': 'FOOBAR',
158+
'webhook_url': 'http://webhook.com',
159+
'apns': {
160+
'alert': {
161+
'title': 'yolo',
162+
'body': 'woot'
163+
}
164+
},
165+
'gcm': {
166+
'notification': {
167+
'title': 'yipee',
168+
'icon': 'huh'
169+
}
170+
},
171+
exception: 'webhook_level must be either INFO or DEBUG. Blank will default to INFO'
172+
}];
173+
174+
for (var index in invalidWebhookConfig) {
175+
var invalidPayload = invalidWebhookConfig[index];
176+
expect(function(){
177+
client.notify(['yolo'], invalidPayload);
178+
}).to.throwException(new RegExp(invalidPayload.exception));
179+
}
180+
})
181+
})

0 commit comments

Comments
 (0)