Skip to content

Native push notifications #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 15, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ var Token = require('./token');
function Config(options) {
options = options || {};

if (options.host) {
this.host = options.host
}
else if (options.cluster) {
this.host = "api-"+options.cluster+".pusher.com";
}
else {
this.host = "api.pusherapp.com";
}

this.scheme = options.scheme || (options.encrypted ? "https" : "http");
this.port = options.port;

Expand All @@ -25,7 +15,7 @@ function Config(options) {
}

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

Config.prototype.getBaseURL = function(subPath, queryString) {
Expand Down
87 changes: 87 additions & 0 deletions lib/notification_client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
var Config = require('./config');
var requests = require('./requests');
var util = require('./util');
var NotificationConfig = require('./notification_config');

var RESTRICTED_GCM_KEYS = ['to', 'registration_ids'];
var GCM_TTL = 241920;
var WEBHOOK_LEVELS = {
"INFO": true,
"DEBUG": true,
"": true
}

function NotificationClient(options){
this.config = new NotificationConfig(options);
}

NotificationClient.prototype.notify = function(interests, notification, callback) {
if (!Array.isArray(interests)) {
throw new Error("Interests must be an array");
}

if (interests.length != 1) {
throw new Error("Currently sending to more than one interest is unsupported")
}


var body = util.mergeObjects({interests: interests}, notification);
this.validateNotification(body);
requests.send(this.config, {
method: "POST",
body: body,
path: "/notification"
}, callback);
}

NotificationClient.prototype.validateNotification = function(notification) {
var gcmPayload = notification.gcm;
if (!gcmPayload && !notification.apns) {
throw new Error("Notification must have fields APNS or GCM");
}

if (gcmPayload) {
for (var index in RESTRICTED_GCM_KEYS) {
var restrictedKey = RESTRICTED_GCM_KEYS[index];
delete(gcmPayload[restrictedKey]);
}

var ttl = gcmPayload.time_to_live;
if (ttl) {
if (isNaN(ttl)) {
throw new Error("provided ttl must be a number");
}

if (!(ttl > 0 && ttl < GCM_TTL)) {
throw new Error("GCM time_to_live must be between 0 and 241920 (4 weeks)");
}
}

var gcmPayloadNotification = gcmPayload.notification;
if (gcmPayloadNotification) {
var title = gcmPayloadNotification.title;
var icon = gcmPayloadNotification.icon;
var requiredFields = [title, icon];

if (typeof(title) !== 'string' || title.length === 0) {
throw new Error("GCM title must be a string and not empty");
}

if (typeof(icon) !== 'string' || icon.length === 0) {
throw new Error("GCM icon must be a string and not empty");
}
}
}

var webhookURL = notification.webhook_url;
var webhookLevel = notification.webhook_level;

if (webhookLevel) {
if (!webhookURL) throw new Error("webhook_level cannot be used without a webhook_url");
if (!WEBHOOK_LEVELS[webhookLevel]) {
throw new Error("webhook_level must be either INFO or DEBUG. Blank will default to INFO");
}
}
}

module.exports = NotificationClient;
19 changes: 19 additions & 0 deletions lib/notification_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
var Config = require('./config');
var util = require('./util');

var DEFAULT_HOST = "nativepushclient-cluster1.pusher.com";
var API_PREFIX = "customer_api";
var API_VERSION = "v1";

function NotificationConfig(options) {
Config.call(this, options);
this.host = options.host || DEFAULT_HOST;
}

util.mergeObjects(NotificationConfig.prototype, Config.prototype);

NotificationConfig.prototype.prefixPath = function(subPath) {
return "/" + API_PREFIX + "/" + API_VERSION + "/apps/" + this.appId + subPath;
};

module.exports = NotificationConfig;
16 changes: 14 additions & 2 deletions lib/pusher.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ var events = require('./events');
var requests = require('./requests');
var util = require('./util');

var Config = require('./config');
var PusherConfig = require('./pusher_config');
var Token = require('./token');
var WebHook = require('./webhook');
var NotificationClient = require('./notification_client');

var validateChannel = function(channel) {
if (typeof channel !== "string" || channel === "" || channel.match(/[^A-Za-z0-9_\-=@,.;]/)) {
Expand Down Expand Up @@ -38,7 +39,9 @@ var validateSocketId = function(socketId) {
* @constructor
* @param {Object} options
* @param {String} [options.host="api.pusherapp.com"] API hostname
* @param {String} [options.notification_host="api.pusherapp.com"] Notification API hostname
* @param {Boolean} [options.encrypted=false] whether to use SSL
* @param {Boolean} [options.notification_encrypted=false] whether to use SSL for notifications
* @param {Integer} [options.port] port, default depends on the scheme
* @param {Integer} options.appId application ID
* @param {String} options.key application key
Expand All @@ -48,7 +51,12 @@ var validateSocketId = function(socketId) {
* @param {Boolean} [options.keepAlive] whether requests should use keep-alive
*/
function Pusher(options) {
this.config = new Config(options);
this.config = new PusherConfig(options);
var notificationOptions = util.mergeObjects({}, options, {
host: options.notificationHost || "yolo.ngrok.io",
encrypted: options.notificationEncrypted
});
this.notificationClient = new NotificationClient(notificationOptions);
}

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

Pusher.prototype.notify = function() {
this.notificationClient.notify.apply(this.notificationClient, arguments);
}

/** Makes a POST request to Pusher, handles the authentication.
*
* Calls back with three arguments - error, request and response. When request
Expand Down
24 changes: 24 additions & 0 deletions lib/pusher_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
var Config = require('./config');
var util = require('./util');

function PusherConfig(options) {
Config.call(this, options);

if (options.host) {
this.host = options.host
}
else if (options.cluster) {
this.host = "api-"+options.cluster+".pusher.com";
}
else {
this.host = "api.pusherapp.com";
}
}

util.mergeObjects(PusherConfig.prototype, Config.prototype);

PusherConfig.prototype.prefixPath = function(subPath) {
return "/apps/" + this.appId + subPath;
};

module.exports = PusherConfig;
181 changes: 181 additions & 0 deletions tests/integration/pusher/notification_client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
var expect = require("expect.js");
var NotificationClient = require("../../../lib/notification_client");
var nock = require('nock');

describe("NativeNotificationClient", function() {
var client;

beforeEach(function(){
client = new NotificationClient({ appId: 1234, key: "f00d", secret: "beef" });
nock.cleanAll();
nock.disableNetConnect();
});

afterEach(function() {
nock.cleanAll();
nock.enableNetConnect();
});

xit("should send in the success case", function(done){
var mock = nock("nativepushclient-cluster1.pusher.com:80")
client.notify(['yolo'],{
'apns': {
'aps': {
'alert':{
'title': 'yolo',
'body': 'woot'
}
}
},
'gcm': {
'notification': {
'title': 'huzzah',
'icon': 'woot'
}
}

}, function(){
expect(mock.isDone()).to.be(true);
done();
});
});

it("should remove restricted GCM keys", function(){
var notification = {
'gcm': {
'to': 'woot',
'registration_ids': ['woot', 'bla'],
'notification': {
'title': 'yipee',
'icon': 'huh'
}
}
};

client.validateNotification(notification);
expect(notification).to.eql({ gcm: { notification: { title: 'yipee', icon: 'huh' }}});
});

it("should validate that either apns or gcm are present", function(){
var notification = {};
expect(function(){
client.validateNotification(notification);
}).to.throwException(/Notification must have fields APNS or GCM/);
});

it("should validate that only one interest is sent to", function(){
expect(function(){
client.notify(['yolo', 'woot'], {});
}).to.throwException(/Currently sending to more than one interest is unsupported/);
})

it("should invalidate certain gcm payloads", function(){
var invalidGcmPayloads = [
{
'gcm': {
'time_to_live': -1,
'notification': {
'title': 'yipee',
'icon': 'huh'
}
},
exception: /GCM time_to_live must be between 0 and 241920 \(4 weeks\)/
},
{
'gcm': {
'time_to_live': 241921,
'notification': {
'title': 'yipee',
'icon': 'huh'
}
},
exception: /GCM time_to_live must be between 0 and 241920 \(4 weeks\)/
},
{
'gcm': {
'notification': {
'title': 'yipee',
}
},
exception: /GCM icon must be a string and not empty/
},
{
'gcm': {
'notification': {
'icon': 'huh'
}
},
exception: /GCM title must be a string and not empty/
},
{
'gcm': {
'notification': {
'title': '',
'icon': 'huh'
}
},
exception: /GCM title must be a string and not empty/
},
{
'gcm': {
'notification': {
'title': 'yipee',
'icon': ''
}
},
exception: /GCM icon must be a string and not empty/
}
]

for (var index in invalidGcmPayloads) {
var invalidPayload = invalidGcmPayloads[index];
expect(function(){
client.notify(['yolo'], invalidPayload);
}).to.throwException(invalidPayload.exception || /%/);
}
});

it("validates webhook config", function(){
invalidWebhookConfig = [
{
'webhook_level': 'DEBUG',
'apns': {
'alert': {
'title': 'yolo',
'body': 'woot'
}
},
'gcm': {
'notification': {
'title': 'yipee',
'icon': 'huh'
}
},
exception: 'webhook_level cannot be used without a webhook_url'
},
{
'webhook_level': 'FOOBAR',
'webhook_url': 'http://webhook.com',
'apns': {
'alert': {
'title': 'yolo',
'body': 'woot'
}
},
'gcm': {
'notification': {
'title': 'yipee',
'icon': 'huh'
}
},
exception: 'webhook_level must be either INFO or DEBUG. Blank will default to INFO'
}];

for (var index in invalidWebhookConfig) {
var invalidPayload = invalidWebhookConfig[index];
expect(function(){
client.notify(['yolo'], invalidPayload);
}).to.throwException(new RegExp(invalidPayload.exception));
}
})
})