From 6afaeb808b9688d1826b7d0f753478064ec0f908 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Mon, 8 Feb 2016 12:02:07 -0800 Subject: [PATCH 1/3] Add support for push --- spec/APNS.spec.js | 10 +- spec/GCM.spec.js | 22 ++- spec/ParsePushAdapter.spec.js | 237 ++++++++++++++++++++++++++ spec/push.spec.js | 25 +-- src/APNS.js | 34 ++-- src/Adapters/Push/ParsePushAdapter.js | 153 +++++++++++++++++ src/Adapters/Push/PushAdapter.js | 29 ++++ src/GCM.js | 54 +++--- src/index.js | 5 + src/push.js | 25 ++- 10 files changed, 530 insertions(+), 64 deletions(-) create mode 100644 spec/ParsePushAdapter.spec.js create mode 100644 src/Adapters/Push/ParsePushAdapter.js create mode 100644 src/Adapters/Push/PushAdapter.js diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 72490e9791..3525fa56bd 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -43,16 +43,18 @@ describe('APNS', () => { 'alert': 'alert' } } - // Mock registrationTokens - var deviceTokens = ['token']; + // Mock devices + var devices = [ + { deviceToken: 'token' } + ]; - var promise = apns.send(data, deviceTokens); + var promise = apns.send(data, devices); expect(sender.pushNotification).toHaveBeenCalled(); var args = sender.pushNotification.calls.first().args; var notification = args[0]; expect(notification.alert).toEqual(data.data.alert); expect(notification.expiry).toEqual(data['expiration_time']); - expect(args[1]).toEqual(deviceTokens); + expect(args[1]).toEqual(['token']); done(); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 4bad883ee4..3e2a794738 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -104,14 +104,18 @@ describe('GCM', () => { 'alert': 'alert' } } - // Mock registrationTokens - var registrationTokens = ['token']; + // Mock devices + var devices = [ + { + deviceToken: 'token' + } + ]; - var promise = gcm.send(data, registrationTokens); + var promise = gcm.send(data, devices); expect(sender.send).toHaveBeenCalled(); var args = sender.send.calls.first().args; // It is too hard to verify message of gcm library, we just verify tokens and retry times - expect(args[1].registrationTokens).toEqual(registrationTokens); + expect(args[1].registrationTokens).toEqual(['token']); expect(args[2]).toEqual(5); done(); }); @@ -123,14 +127,16 @@ describe('GCM', () => { send: jasmine.createSpy('send') }; gcm.sender = sender; - // Mock registrationTokens - var registrationTokens = []; + // Mock devices + var devices = []; for (var i = 0; i <= 2000; i++) { - registrationTokens.push(i.toString()); + devices.push({ + deviceToken: i.toString() + }); } expect(function() { - gcm.send({}, registrationTokens); + gcm.send({}, devices); }).toThrow(); done(); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js new file mode 100644 index 0000000000..ca0da19063 --- /dev/null +++ b/spec/ParsePushAdapter.spec.js @@ -0,0 +1,237 @@ +var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); + +describe('ParsePushAdapter', () => { + it('can be initialized', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.validPushTypes).toEqual(['ios', 'android']); + done(); + }); + + it('can initialize', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: { + senderId: 'senderId', + apiKey: 'apiKey' + }, + ios: [ + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true + }, + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false + } + ] + }; + + parsePushAdapter.initialize(pushConfig); + // Check ios + var iosSenders = parsePushAdapter.senders['ios']; + expect(iosSenders.length).toBe(2); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = iosSenders[0].sender.options; + expect(prodApnsOptions.cert).toBe(pushConfig.ios[0].cert); + expect(prodApnsOptions.key).toBe(pushConfig.ios[0].key); + expect(prodApnsOptions.production).toBe(pushConfig.ios[0].production); + var devApnsOptions = iosSenders[1].sender.options; + expect(devApnsOptions.cert).toBe(pushConfig.ios[1].cert); + expect(devApnsOptions.key).toBe(pushConfig.ios[1].key); + expect(devApnsOptions.production).toBe(pushConfig.ios[1].production); + // Check android + var androidSenders = parsePushAdapter.senders['android']; + expect(androidSenders.length).toBe(1); + var androidSender = androidSenders[0]; + // TODO: Remove this checking onec we inject GCM + expect(androidSender.sender.key).toBe(pushConfig.android.apiKey); + done(); + }); + + it('can throw on initializing with unsupported push type', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + win: { + senderId: 'senderId', + apiKey: 'apiKey' + } + }; + + expect(function() { + parsePushAdapter.initialize(pushConfig) + }).toThrow(); + done(); + }); + + it('can throw on initializing with invalid pushConfig', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: 123 + }; + + expect(function() { + parsePushAdapter.initialize(pushConfig) + }).toThrow(); + done(); + }); + + it('can get push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock push senders + var androidSender = {}; + var iosSender = {}; + var iosSenderAgain = {}; + parsePushAdapter.senders = { + android: [ + androidSender + ], + ios: [ + iosSender, + iosSenderAgain + ] + }; + + expect(parsePushAdapter.getPushSenders('android')).toEqual([androidSender]); + expect(parsePushAdapter.getPushSenders('ios')).toEqual([iosSender, iosSenderAgain]); + done(); + }); + + it('can get empty push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getPushSenders('android')).toEqual([]); + done(); + }); + + it('can get valid push types', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can classify installation', (done) => { + // Mock installations + var validPushTypes = ['ios', 'android']; + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + var deviceTokenMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceTokenMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceTokenMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceTokenMap['win']).toBe(undefined); + done(); + }); + + it('can slice ios devices', (done) => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; + + var chunkDevices = ParsePushAdapter.sliceDevices('ios', devices, 2); + expect(chunkDevices).toEqual([devices]); + done(); + }); + + it('can slice android devices', (done) => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; + + var chunkDevices = ParsePushAdapter.sliceDevices('android', devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)] + ]); + done(); + }); + + + it('can send push notifications', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock android ios senders + var androidSender = { + send: jasmine.createSpy('send') + }; + var iosSender = { + send: jasmine.createSpy('send') + }; + var iosSenderAgain = { + send: jasmine.createSpy('send') + }; + var senders = { + ios: [iosSender, iosSenderAgain], + android: [androidSender] + }; + parsePushAdapter.senders = senders; + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + parsePushAdapter.send(data, installations); + // Check android sender + expect(androidSender.send).toHaveBeenCalled(); + var args = androidSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender.send).toHaveBeenCalled(); + args = iosSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + expect(iosSenderAgain.send).toHaveBeenCalled(); + args = iosSenderAgain.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken + }; + } +}); diff --git a/spec/push.spec.js b/spec/push.spec.js index a2ea41b5e6..ba7588f36b 100644 --- a/spec/push.spec.js +++ b/spec/push.spec.js @@ -104,10 +104,11 @@ describe('push', () => { it('can validate device type when no device type is set', (done) => { // Make query condition var where = { - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -116,10 +117,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'ios' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -130,10 +132,11 @@ describe('push', () => { 'deviceType': { '$in': ['android', 'ios'] } - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -142,10 +145,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -154,10 +158,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where) + push.validatePushType(where, validPushTypes); }).toThrow(); done(); }); diff --git a/src/APNS.js b/src/APNS.js index 85c97401a5..e627536944 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,7 +1,9 @@ -var Parse = require('parse/node').Parse; +"use strict"; + +const Parse = require('parse/node').Parse; // TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, // but probably we will replace it in the future. -var apn = require('apn'); +const apn = require('apn'); /** * Create a new connection to the APN service. @@ -33,18 +35,26 @@ function APNS(args) { }); this.sender.on("socketError", console.error); + + this.sender.on("transmitted", function(notification, device) { + console.log("APNS Notification transmitted to:" + device.token.toString("hex")); + }); } /** * Send apns request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} deviceTokens A array of device tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved immediately */ -APNS.prototype.send = function(data, deviceTokens) { - var coreData = data.data; - var expirationTime = data['expiration_time']; - var notification = generateNotification(coreData, expirationTime); +APNS.prototype.send = function(data, devices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData, expirationTime); + let deviceTokens = []; + for (let device of devices) { + deviceTokens.push(device.deviceToken); + } this.sender.pushNotification(notification, deviceTokens); // TODO: pushNotification will push the notification to apn's queue. // We do not handle error in V1, we just relies apn to auto retry and send the @@ -57,10 +67,10 @@ APNS.prototype.send = function(data, deviceTokens) { * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -var generateNotification = function(coreData, expirationTime) { - var notification = new apn.notification(); - var payload = {}; - for (var key in coreData) { +let generateNotification = function(coreData, expirationTime) { + let notification = new apn.notification(); + let payload = {}; + for (let key in coreData) { switch (key) { case 'alert': notification.setAlertText(coreData.alert); @@ -73,7 +83,7 @@ var generateNotification = function(coreData, expirationTime) { break; case 'content-available': notification.setNewsstandAvailable(true); - var isAvailable = coreData['content-available'] === 1; + let isAvailable = coreData['content-available'] === 1; notification.setContentAvailable(isAvailable); break; case 'category': diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js new file mode 100644 index 0000000000..55b99032e6 --- /dev/null +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -0,0 +1,153 @@ +"use strict"; +// ParsePushAdapter is the default implementation of +// PushAdapter, it uses GCM for android push and APNS +// for ios push. + +const Parse = require('parse/node').Parse; +const GCM = require('../../GCM'); +const APNS = require('../../APNS'); + +function ParsePushAdapter() { + this.validPushTypes = ['ios', 'android']; + this.senders = {}; +} + +/** + * Register push senders + * @param {Object} pushConfig The push configuration which is given when parse server is initialized + */ +ParsePushAdapter.prototype.initialize = function(pushConfig) { + // Initialize senders + for (let validPushType of this.validPushTypes) { + this.senders[validPushType] = []; + } + + pushConfig = pushConfig || {}; + let pushTypes = Object.keys(pushConfig); + for (let pushType of pushTypes) { + if (this.validPushTypes.indexOf(pushType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push to ' + pushTypes + ' is not supported'); + } + + let typePushConfig = pushConfig[pushType]; + let senderArgs = []; + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + if (Array.isArray(typePushConfig)) { + senderArgs = senderArgs.concat(typePushConfig); + } else if (typeof typePushConfig === 'object') { + senderArgs.push(typePushConfig); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push Configuration is invalid'); + } + for (let senderArg of senderArgs) { + let sender; + switch (pushType) { + case 'ios': + sender = new APNS(senderArg); + break; + case 'android': + sender = new GCM(senderArg); + break; + } + this.senders[pushType].push(sender); + } + } +} + +/** + * Get an array of push senders based on the push type. + * @param {String} The push type + * @returns {Array|Undefined} An array of push senders + */ +ParsePushAdapter.prototype.getPushSenders = function(pushType) { + if (!this.senders[pushType]) { + console.log('No push sender for push type %s', pushType); + return []; + } + return this.senders[pushType]; +} + +/** + * Get an array of valid push types. + * @returns {Array} An array of valid push types + */ +ParsePushAdapter.prototype.getValidPushTypes = function() { + return this.validPushTypes; +} + +ParsePushAdapter.prototype.send = function(data, installations) { + let deviceMap = classifyInstallation(installations, this.validPushTypes); + let sendPromises = []; + for (let pushType in deviceMap) { + let senders = this.getPushSenders(pushType); + // Since ios have dev/prod cert, a push type may have multiple senders + for (let sender of senders) { + let devices = deviceMap[pushType]; + if (!sender || devices.length == 0) { + continue; + } + // For android, we can only have 1000 recepients per send + let chunkDevices = sliceDevices(pushType, devices, GCM.GCMRegistrationTokensMax); + for (let chunkDevice of chunkDevices) { + sendPromises.push(sender.send(data, chunkDevice)); + } + } + } + return Parse.Promise.when(sendPromises); +} + +/** + * Classify the device token of installations based on its device type. + * @param {Object} installations An array of installations + * @param {Array} validPushTypes An array of valid push types(string) + * @returns {Object} A map whose key is device type and value is an array of device + */ +function classifyInstallation(installations, validPushTypes) { + // Init deviceTokenMap, create a empty array for each valid pushType + let deviceMap = {}; + for (let validPushType of validPushTypes) { + deviceMap[validPushType] = []; + } + for (let installation of installations) { + // No deviceToken, ignore + if (!installation.deviceToken) { + continue; + } + let pushType = installation.deviceType; + if (deviceMap[pushType]) { + deviceMap[pushType].push({ + deviceToken: installation.deviceToken + }); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceMap; +} + +/** + * Slice a list of devices to several list of devices with fixed chunk size. + * @param {String} pushType The push type of the given device tokens + * @param {Array} devices An array of devices + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of devices with fixed chunk size + */ +function sliceDevices(pushType, devices, chunkSize) { + if (pushType !== 'android') { + return [devices]; + } + let chunkDevices = []; + while (devices.length > 0) { + chunkDevices.push(devices.splice(0, chunkSize)); + } + return chunkDevices; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + ParsePushAdapter.classifyInstallation = classifyInstallation; + ParsePushAdapter.sliceDevices = sliceDevices; +} +module.exports = ParsePushAdapter; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js new file mode 100644 index 0000000000..ab2f71333b --- /dev/null +++ b/src/Adapters/Push/PushAdapter.js @@ -0,0 +1,29 @@ +// Push Adapter +// +// Allows you to change the push notification mechanism. +// +// Adapter classes must implement the following functions: +// * initialize(pushConfig) +// * getPushSenders(parseConfig) +// * getValidPushTypes(parseConfig) +// * send(devices, installations) +// +// Default is ParsePushAdapter, which uses GCM for +// android push and APNS for ios push. + +var ParsePushAdapter = require('./ParsePushAdapter'); + +var adapter = new ParsePushAdapter(); + +function setAdapter(pushAdapter) { + adapter = pushAdapter; +} + +function getAdapter() { + return adapter; +} + +module.exports = { + getAdapter: getAdapter, + setAdapter: setAdapter +}; diff --git a/src/GCM.js b/src/GCM.js index b9d5c728d7..9dfe1b06b7 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -1,44 +1,54 @@ -var Parse = require('parse/node').Parse; -var gcm = require('node-gcm'); -var randomstring = require('randomstring'); +"use strict"; -var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks -var GCMRegistrationTokensMax = 1000; +const Parse = require('parse/node').Parse; +const gcm = require('node-gcm'); +const randomstring = require('randomstring'); -function GCM(apiKey) { - this.sender = new gcm.Sender(apiKey); +const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks +const GCMRegistrationTokensMax = 1000; + +function GCM(args) { + this.sender = new gcm.Sender(args.apiKey); } /** * Send gcm request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} registrationTokens A array of registration tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved after we get results from gcm */ -GCM.prototype.send = function (data, registrationTokens) { - if (registrationTokens.length >= GCMRegistrationTokensMax) { +GCM.prototype.send = function(data, devices) { + if (devices.length >= GCMRegistrationTokensMax) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Too many registration tokens for a GCM request.'); } - var pushId = randomstring.generate({ + let pushId = randomstring.generate({ length: 10, charset: 'alphanumeric' }); - var timeStamp = Date.now(); - var expirationTime; + let timeStamp = Date.now(); + let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date // in Unix epoch time in milliseconds here if (data['expiration_time']) { expirationTime = data['expiration_time']; } // Generate gcm payload - var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); // Make and send gcm request - var message = new gcm.Message(gcmPayload); - var promise = new Parse.Promise(); - this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) { + let message = new gcm.Message(gcmPayload); + let promise = new Parse.Promise(); + let registrationTokens = [] + for (let device of devices) { + registrationTokens.push(device.deviceToken); + } + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { // TODO: Use the response from gcm to generate and save push report // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + console.log('GCM request and response %j', { + request: message, + response: response + }); promise.resolve(); }); return promise; @@ -52,19 +62,19 @@ GCM.prototype.send = function (data, registrationTokens) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { - var payloadData = { +let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { + let payloadData = { 'time': new Date(timeStamp).toISOString(), 'push_id': pushId, 'data': JSON.stringify(coreData) } - var payload = { + let payload = { priority: 'normal', data: payloadData }; if (expirationTime) { // The timeStamp and expiration is in milliseconds but gcm requires second - var timeToLive = Math.floor((expirationTime - timeStamp) / 1000); + let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); if (timeToLive < 0) { timeToLive = 0; } @@ -76,6 +86,8 @@ var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } +GCM.GCMRegistrationTokensMax = GCMRegistrationTokensMax; + if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; } diff --git a/src/index.js b/src/index.js index ef29ec7f39..1bc02dc7b1 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), + PushAdapter = require('./Adapters/Push/PushAdapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, @@ -86,6 +87,10 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + // Register push senders + var pushConfig = args.push; + PushAdapter.getAdapter().initialize(pushConfig); + // Initialize the node client SDK automatically Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); if(args.serverURL) { diff --git a/src/push.js b/src/push.js index 29a6a944e5..013b85d460 100644 --- a/src/push.js +++ b/src/push.js @@ -2,27 +2,34 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), + PushAdapter = require('./Adapters/Push/PushAdapter'), rest = require('./rest'); -var validPushTypes = ['ios', 'android']; - function handlePushWithoutQueue(req) { validateMasterKey(req); var where = getQueryCondition(req); - validateDeviceType(where); + var pushAdapter = PushAdapter.getAdapter(); + validatePushType(where, pushAdapter.getValidPushTypes()); // Replace the expiration_time with a valid Unix epoch milliseconds time req.body['expiration_time'] = getExpirationTime(req); - return rest.find(req.config, req.auth, '_Installation', where).then(function(response) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); + // TODO: If the req can pass the checking, we return immediately instead of waiting + // pushes to be sent. We probably change this behaviour in the future. + rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + return pushAdapter.send(req.body, response.results); + }); + return Parse.Promise.as({ + response: { + 'result': true + } }); } /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition + * @param {Array} validPushTypes An array of valid push types(string) */ -function validateDeviceType(where) { +function validatePushType(where, validPushTypes) { var where = where || {}; var deviceTypeField = where.deviceType || {}; var deviceTypes = []; @@ -113,12 +120,12 @@ var router = new PromiseRouter(); router.route('POST','/push', handlePushWithoutQueue); module.exports = { - router: router + router: router, } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { module.exports.getQueryCondition = getQueryCondition; module.exports.validateMasterKey = validateMasterKey; module.exports.getExpirationTime = getExpirationTime; - module.exports.validateDeviceType = validateDeviceType; + module.exports.validatePushType = validatePushType; } From 06b1ee2362b9a3ad08e31a6bc6598ef2962c70bd Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Wed, 10 Feb 2016 12:03:02 -0800 Subject: [PATCH 2/3] Make push follow controller and adapter style --- spec/ParsePushAdapter.spec.js | 16 +--- spec/{push.spec.js => PushController.spec.js} | 32 ++++---- src/Adapters/Push/ParsePushAdapter.js | 12 +-- src/Adapters/Push/PushAdapter.js | 22 ++---- .../PushController.js} | 78 +++++++++++-------- src/index.js | 25 ++++-- 6 files changed, 90 insertions(+), 95 deletions(-) rename spec/{push.spec.js => PushController.spec.js} (80%) rename src/{push.js => Controllers/PushController.js} (67%) diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index ca0da19063..1f860a90d1 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -2,14 +2,6 @@ var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); describe('ParsePushAdapter', () => { it('can be initialized', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - - expect(parsePushAdapter.validPushTypes).toEqual(['ios', 'android']); - done(); - }); - - it('can initialize', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { android: { @@ -30,7 +22,7 @@ describe('ParsePushAdapter', () => { ] }; - parsePushAdapter.initialize(pushConfig); + var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSenders = parsePushAdapter.senders['ios']; expect(iosSenders.length).toBe(2); @@ -53,7 +45,6 @@ describe('ParsePushAdapter', () => { }); it('can throw on initializing with unsupported push type', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { win: { @@ -63,20 +54,19 @@ describe('ParsePushAdapter', () => { }; expect(function() { - parsePushAdapter.initialize(pushConfig) + new ParsePushAdapter(pushConfig); }).toThrow(); done(); }); it('can throw on initializing with invalid pushConfig', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { android: 123 }; expect(function() { - parsePushAdapter.initialize(pushConfig) + new ParsePushAdapter(pushConfig); }).toThrow(); done(); }); diff --git a/spec/push.spec.js b/spec/PushController.spec.js similarity index 80% rename from spec/push.spec.js rename to spec/PushController.spec.js index ba7588f36b..5414eca2bd 100644 --- a/spec/push.spec.js +++ b/spec/PushController.spec.js @@ -1,6 +1,6 @@ -var push = require('../src/push'); +var PushController = require('../src/Controllers/PushController').PushController; -describe('push', () => { +describe('PushController', () => { it('can check valid master key of request', (done) => { // Make mock request var request = { @@ -13,7 +13,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).not.toThrow(); done(); }); @@ -30,7 +30,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).toThrow(); done(); }); @@ -43,7 +43,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'channels': { '$in': ['Giants', 'Mets'] @@ -62,7 +62,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'injuryReports': true }); @@ -77,7 +77,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -96,7 +96,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -108,7 +108,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -121,7 +121,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -136,7 +136,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -149,7 +149,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -162,7 +162,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -176,7 +176,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(new Date(timeStr).valueOf()); done(); }); @@ -190,7 +190,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(timeNumber * 1000); done(); }); @@ -204,7 +204,7 @@ describe('push', () => { } expect(function(){ - push.getExpirationTime(request); + PushController.getExpirationTime(request); }).toThrow(); done(); }); diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 55b99032e6..43987bc59e 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -7,16 +7,10 @@ const Parse = require('parse/node').Parse; const GCM = require('../../GCM'); const APNS = require('../../APNS'); -function ParsePushAdapter() { - this.validPushTypes = ['ios', 'android']; - this.senders = {}; -} +function ParsePushAdapter(pushConfig) { + this.validPushTypes = ['ios', 'android']; + this.senders = {}; -/** - * Register push senders - * @param {Object} pushConfig The push configuration which is given when parse server is initialized - */ -ParsePushAdapter.prototype.initialize = function(pushConfig) { // Initialize senders for (let validPushType of this.validPushTypes) { this.senders[validPushType] = []; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js index ab2f71333b..1e07467fa0 100644 --- a/src/Adapters/Push/PushAdapter.js +++ b/src/Adapters/Push/PushAdapter.js @@ -3,27 +3,15 @@ // Allows you to change the push notification mechanism. // // Adapter classes must implement the following functions: -// * initialize(pushConfig) -// * getPushSenders(parseConfig) -// * getValidPushTypes(parseConfig) +// * getValidPushTypes() // * send(devices, installations) // // Default is ParsePushAdapter, which uses GCM for // android push and APNS for ios push. +export class PushAdapter { + send(devices, installations) { } -var ParsePushAdapter = require('./ParsePushAdapter'); - -var adapter = new ParsePushAdapter(); - -function setAdapter(pushAdapter) { - adapter = pushAdapter; -} - -function getAdapter() { - return adapter; + getValidPushTypes() { } } -module.exports = { - getAdapter: getAdapter, - setAdapter: setAdapter -}; +export default PushAdapter; diff --git a/src/push.js b/src/Controllers/PushController.js similarity index 67% rename from src/push.js rename to src/Controllers/PushController.js index 013b85d460..9f9252dcc8 100644 --- a/src/push.js +++ b/src/Controllers/PushController.js @@ -1,27 +1,44 @@ -// push.js +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - PushAdapter = require('./Adapters/Push/PushAdapter'), - rest = require('./rest'); +export class PushController { -function handlePushWithoutQueue(req) { - validateMasterKey(req); - var where = getQueryCondition(req); - var pushAdapter = PushAdapter.getAdapter(); - validatePushType(where, pushAdapter.getValidPushTypes()); - // Replace the expiration_time with a valid Unix epoch milliseconds time - req.body['expiration_time'] = getExpirationTime(req); - // TODO: If the req can pass the checking, we return immediately instead of waiting - // pushes to be sent. We probably change this behaviour in the future. - rest.find(req.config, req.auth, '_Installation', where).then(function(response) { - return pushAdapter.send(req.body, response.results); - }); - return Parse.Promise.as({ - response: { - 'result': true - } - }); + constructor(pushAdapter) { + this._pushAdapter = pushAdapter; + } + + handlePOST(req) { + if (!this._pushAdapter) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push adapter is not availabe'); + } + + validateMasterKey(req); + var where = getQueryCondition(req); + var pushAdapter = this._pushAdapter; + validatePushType(where, pushAdapter.getValidPushTypes()); + // Replace the expiration_time with a valid Unix epoch milliseconds time + req.body['expiration_time'] = getExpirationTime(req); + // TODO: If the req can pass the checking, we return immediately instead of waiting + // pushes to be sent. We probably change this behaviour in the future. + rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + return pushAdapter.send(req.body, response.results); + }); + return Parse.Promise.as({ + response: { + 'result': true + } + }); + } + + getExpressRouter() { + var router = new PromiseRouter(); + router.route('POST','/push', (req) => { + return this.handlePOST(req); + }); + return router; + } } /** @@ -116,16 +133,11 @@ function validateMasterKey(req) { } } -var router = new PromiseRouter(); -router.route('POST','/push', handlePushWithoutQueue); - -module.exports = { - router: router, -} - if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { - module.exports.getQueryCondition = getQueryCondition; - module.exports.validateMasterKey = validateMasterKey; - module.exports.getExpirationTime = getExpirationTime; - module.exports.validatePushType = validatePushType; + PushController.getQueryCondition = getQueryCondition; + PushController.validateMasterKey = validateMasterKey; + PushController.getExpirationTime = getExpirationTime; + PushController.validatePushType = validatePushType; } + +export default PushController; diff --git a/src/index.js b/src/index.js index 1bc02dc7b1..16c1509806 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,6 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), - PushAdapter = require('./Adapters/Push/PushAdapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, @@ -14,9 +13,12 @@ var batch = require('./batch'), import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; - import { FilesController } from './Controllers/FilesController'; +import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; +import { PushController } from './Controllers/PushController'; + + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -42,6 +44,8 @@ addParseCloud(); // "dotNetKey": optional key from Parse dashboard // "restAPIKey": optional key from Parse dashboard // "javascriptKey": optional key from Parse dashboard +// "push": optional key from configure push + function ParseServer(args) { if (!args.appId || !args.masterKey) { throw 'You must provide an appId and masterKey!'; @@ -51,8 +55,18 @@ function ParseServer(args) { DatabaseAdapter.setAdapter(args.databaseAdapter); } + // Make files adapter let filesAdapter = args.filesAdapter || new GridStoreAdapter(); + // Make push adapter + let pushConfig = args.push; + let pushAdapter; + if (pushConfig && pushConfig.adapter) { + pushAdapter = pushConfig.adapter; + } else if (pushConfig) { + pushAdapter = new ParsePushAdapter(pushConfig) + } + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -87,10 +101,6 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } - // Register push senders - var pushConfig = args.push; - PushAdapter.getAdapter().initialize(pushConfig); - // Initialize the node client SDK automatically Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); if(args.serverURL) { @@ -122,13 +132,14 @@ function ParseServer(args) { router.merge(require('./sessions')); router.merge(require('./roles')); router.merge(require('./analytics')); - router.merge(require('./push').router); router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { router.merge(require('./global_config')); } + let pushController = new PushController(pushAdapter); + router.merge(pushController.getExpressRouter()); batch.mountOnto(router); From 273a20767b41eddd75983bc76b4ea1c0014a78d0 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Thu, 11 Feb 2016 02:13:23 -0800 Subject: [PATCH 3/3] Change APNS multiple certs handling --- spec/APNS.spec.js | 263 +++++++++++++++++++++++++- spec/GCM.spec.js | 52 +++-- spec/ParsePushAdapter.spec.js | 119 ++---------- src/APNS.js | 157 ++++++++++++--- src/Adapters/Push/ParsePushAdapter.js | 93 ++------- src/GCM.js | 62 ++++-- 6 files changed, 499 insertions(+), 247 deletions(-) diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 3525fa56bd..c56e35d550 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,6 +1,65 @@ var APNS = require('../src/APNS'); describe('APNS', () => { + + it('can initialize with single cert', (done) => { + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + + expect(apns.conns.length).toBe(1); + var apnsConnection = apns.conns[0]; + expect(apnsConnection.index).toBe(0); + expect(apnsConnection.bundleId).toBe(args.bundleId); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = apnsConnection.options; + expect(prodApnsOptions.cert).toBe(args.cert); + expect(prodApnsOptions.key).toBe(args.key); + expect(prodApnsOptions.production).toBe(args.production); + done(); + }); + + it('can initialize with multiple certs', (done) => { + var args = [ + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false, + bundleId: 'bundleId' + }, + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleIdAgain' + } + ] + + var apns = new APNS(args); + expect(apns.conns.length).toBe(2); + var devApnsConnection = apns.conns[1]; + expect(devApnsConnection.index).toBe(1); + var devApnsOptions = devApnsConnection.options; + expect(devApnsOptions.cert).toBe(args[0].cert); + expect(devApnsOptions.key).toBe(args[0].key); + expect(devApnsOptions.production).toBe(args[0].production); + expect(devApnsConnection.bundleId).toBe(args[0].bundleId); + + var prodApnsConnection = apns.conns[0]; + expect(prodApnsConnection.index).toBe(0); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = prodApnsConnection.options; + expect(prodApnsOptions.cert).toBe(args[1].cert); + expect(prodApnsOptions.key).toBe(args[1].key); + expect(prodApnsOptions.production).toBe(args[1].production); + expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); + done(); + }); + it('can generate APNS notification', (done) => { //Mock request data var data = { @@ -29,12 +88,195 @@ describe('APNS', () => { done(); }); + it('can choose conns for device without appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = {}; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0, 1]); + done(); + }); + + it('can choose conns for device with valid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'bundleId' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0]); + done(); + }); + + it('can choose conns for device with invalid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'invalid' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([]); + done(); + }); + + it('can handle transmission error when notification is not in cache or device is missing', (done) => { + // Mock conns + var conns = []; + var errorCode = 1; + var notification = undefined; + var device = {}; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + + var notification = {}; + var device = undefined; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + done(); + }); + + it('can handle transmission error when there are other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 0, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when there is no other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + } + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 2, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + expect(conns[3].pushNotification).not.toHaveBeenCalled(); + expect(conns[4].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when device has no appIdentifier', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId3' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 1, + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).toHaveBeenCalled(); + done(); + }); + it('can send APNS notification', (done) => { - var apns = new APNS(); - var sender = { - pushNotification: jasmine.createSpy('send') + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + var conn = { + pushNotification: jasmine.createSpy('send'), + bundleId: 'bundleId' }; - apns.sender = sender; + apns.conns = [ conn ]; // Mock data var expirationTime = 1454571491354 var data = { @@ -45,16 +287,21 @@ describe('APNS', () => { } // Mock devices var devices = [ - { deviceToken: 'token' } + { + deviceToken: '112233', + appIdentifier: 'bundleId' + } ]; var promise = apns.send(data, devices); - expect(sender.pushNotification).toHaveBeenCalled(); - var args = sender.pushNotification.calls.first().args; + expect(conn.pushNotification).toHaveBeenCalled(); + var args = conn.pushNotification.calls.first().args; var notification = args[0]; expect(notification.alert).toEqual(data.data.alert); expect(notification.expiry).toEqual(data['expiration_time']); - expect(args[1]).toEqual(['token']); + var apnDevice = args[1] + expect(apnDevice.connIndex).toEqual(0); + expect(apnDevice.appIdentifier).toEqual('bundleId'); done(); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 3e2a794738..30f1a99788 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,6 +1,23 @@ var GCM = require('../src/GCM'); describe('GCM', () => { + it('can initialize', (done) => { + var args = { + apiKey: 'apiKey' + }; + var gcm = new GCM(args); + expect(gcm.sender.key).toBe(args.apiKey); + done(); + }); + + it('can throw on initializing with invalid args', (done) => { + var args = 123 + expect(function() { + new GCM(args); + }).toThrow(); + done(); + }); + it('can generate GCM Payload without expiration time', (done) => { //Mock request data var data = { @@ -90,7 +107,9 @@ describe('GCM', () => { }); it('can send GCM request', (done) => { - var gcm = new GCM('apiKey'); + var gcm = new GCM({ + apiKey: 'apiKey' + }); // Mock gcm sender var sender = { send: jasmine.createSpy('send') @@ -111,7 +130,7 @@ describe('GCM', () => { } ]; - var promise = gcm.send(data, devices); + gcm.send(data, devices); expect(sender.send).toHaveBeenCalled(); var args = sender.send.calls.first().args; // It is too hard to verify message of gcm library, we just verify tokens and retry times @@ -120,24 +139,21 @@ describe('GCM', () => { done(); }); - it('can throw on sending when we have too many registration tokens', (done) => { - var gcm = new GCM('apiKey'); - // Mock gcm sender - var sender = { - send: jasmine.createSpy('send') - }; - gcm.sender = sender; + it('can slice devices', (done) => { // Mock devices - var devices = []; - for (var i = 0; i <= 2000; i++) { - devices.push({ - deviceToken: i.toString() - }); - } + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - expect(function() { - gcm.send({}, devices); - }).toThrow(); + var chunkDevices = GCM.sliceDevices(devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)] + ]); done(); }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken + }; + } }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 1f860a90d1..4cc4f0f85f 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -1,4 +1,6 @@ var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); +var APNS = require('../src/APNS'); +var GCM = require('../src/GCM'); describe('ParsePushAdapter', () => { it('can be initialized', (done) => { @@ -12,35 +14,25 @@ describe('ParsePushAdapter', () => { { cert: 'prodCert.pem', key: 'prodKey.pem', - production: true + production: true, + bundleId: 'bundleId' }, { cert: 'devCert.pem', key: 'devKey.pem', - production: false + production: false, + bundleId: 'bundleIdAgain' } ] }; var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios - var iosSenders = parsePushAdapter.senders['ios']; - expect(iosSenders.length).toBe(2); - // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = iosSenders[0].sender.options; - expect(prodApnsOptions.cert).toBe(pushConfig.ios[0].cert); - expect(prodApnsOptions.key).toBe(pushConfig.ios[0].key); - expect(prodApnsOptions.production).toBe(pushConfig.ios[0].production); - var devApnsOptions = iosSenders[1].sender.options; - expect(devApnsOptions.cert).toBe(pushConfig.ios[1].cert); - expect(devApnsOptions.key).toBe(pushConfig.ios[1].key); - expect(devApnsOptions.production).toBe(pushConfig.ios[1].production); + var iosSender = parsePushAdapter.senderMap['ios']; + expect(iosSender instanceof APNS).toBe(true); // Check android - var androidSenders = parsePushAdapter.senders['android']; - expect(androidSenders.length).toBe(1); - var androidSender = androidSenders[0]; - // TODO: Remove this checking onec we inject GCM - expect(androidSender.sender.key).toBe(pushConfig.android.apiKey); + var androidSender = parsePushAdapter.senderMap['android']; + expect(androidSender instanceof GCM).toBe(true); done(); }); @@ -59,46 +51,6 @@ describe('ParsePushAdapter', () => { done(); }); - it('can throw on initializing with invalid pushConfig', (done) => { - // Make mock config - var pushConfig = { - android: 123 - }; - - expect(function() { - new ParsePushAdapter(pushConfig); - }).toThrow(); - done(); - }); - - it('can get push senders', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - // Mock push senders - var androidSender = {}; - var iosSender = {}; - var iosSenderAgain = {}; - parsePushAdapter.senders = { - android: [ - androidSender - ], - ios: [ - iosSender, - iosSenderAgain - ] - }; - - expect(parsePushAdapter.getPushSenders('android')).toEqual([androidSender]); - expect(parsePushAdapter.getPushSenders('ios')).toEqual([iosSender, iosSenderAgain]); - done(); - }); - - it('can get empty push senders', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - - expect(parsePushAdapter.getPushSenders('android')).toEqual([]); - done(); - }); - it('can get valid push types', (done) => { var parsePushAdapter = new ParsePushAdapter(); @@ -128,31 +80,10 @@ describe('ParsePushAdapter', () => { } ]; - var deviceTokenMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); - expect(deviceTokenMap['android']).toEqual([makeDevice('androidToken')]); - expect(deviceTokenMap['ios']).toEqual([makeDevice('iosToken')]); - expect(deviceTokenMap['win']).toBe(undefined); - done(); - }); - - it('can slice ios devices', (done) => { - // Mock devices - var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - - var chunkDevices = ParsePushAdapter.sliceDevices('ios', devices, 2); - expect(chunkDevices).toEqual([devices]); - done(); - }); - - it('can slice android devices', (done) => { - // Mock devices - var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - - var chunkDevices = ParsePushAdapter.sliceDevices('android', devices, 3); - expect(chunkDevices).toEqual([ - [makeDevice(1), makeDevice(2), makeDevice(3)], - [makeDevice(4)] - ]); + var deviceMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceMap['win']).toBe(undefined); done(); }); @@ -166,14 +97,11 @@ describe('ParsePushAdapter', () => { var iosSender = { send: jasmine.createSpy('send') }; - var iosSenderAgain = { - send: jasmine.createSpy('send') - }; - var senders = { - ios: [iosSender, iosSenderAgain], - android: [androidSender] + var senderMap = { + ios: iosSender, + android: androidSender }; - parsePushAdapter.senders = senders; + parsePushAdapter.senderMap = senderMap; // Mock installations var installations = [ { @@ -210,18 +138,13 @@ describe('ParsePushAdapter', () => { expect(args[1]).toEqual([ makeDevice('iosToken') ]); - expect(iosSenderAgain.send).toHaveBeenCalled(); - args = iosSenderAgain.send.calls.first().args; - expect(args[0]).toEqual(data); - expect(args[1]).toEqual([ - makeDevice('iosToken') - ]); done(); }); - function makeDevice(deviceToken) { + function makeDevice(deviceToken, appIdentifier) { return { - deviceToken: deviceToken + deviceToken: deviceToken, + appIdentifier: appIdentifier }; } }); diff --git a/src/APNS.js b/src/APNS.js index e627536944..500be9e23f 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -8,37 +8,77 @@ const apn = require('apn'); /** * Create a new connection to the APN service. * @constructor - * @param {Object} args Arguments to config APNS connection - * @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem - * @param {String} args.key The filename of the connection key to load from disk, default is key.pem + * @param {Object|Array} args An argument or a list of arguments to config APNS connection + * @param {String} args.cert The filename of the connection certificate to load from disk + * @param {String} args.key The filename of the connection key to load from disk + * @param {String} args.pfx The filename for private key, certificate and CA certs in PFX or PKCS12 format, it will overwrite cert and key * @param {String} args.passphrase The passphrase for the connection key, if required + * @param {String} args.bundleId The bundleId for cert * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox */ function APNS(args) { - this.sender = new apn.connection(args); + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + let apnsArgsList = []; + if (Array.isArray(args)) { + apnsArgsList = apnsArgsList.concat(args); + } else if (typeof args === 'object') { + apnsArgsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'APNS Configuration is invalid'); + } - this.sender.on('connected', function() { - console.log('APNS Connected'); - }); + this.conns = []; + for (let apnsArgs of apnsArgsList) { + let conn = new apn.Connection(apnsArgs); + if (!apnsArgs.bundleId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'BundleId is mssing for %j', apnsArgs); + } + conn.bundleId = apnsArgs.bundleId; + // Set the priority of the conns, prod cert has higher priority + if (apnsArgs.production) { + conn.priority = 0; + } else { + conn.priority = 1; + } - this.sender.on('transmissionError', function(errCode, notification, device) { - console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification); - // TODO: For error caseud by invalid deviceToken, we should mark those installations. - }); + // Set apns client callbacks + conn.on('connected', () => { + console.log('APNS Connection %d Connected', conn.index); + }); - this.sender.on("timeout", function () { - console.log("APNS Connection Timeout"); - }); + conn.on('transmissionError', (errCode, notification, apnDevice) => { + handleTransmissionError(this.conns, errCode, notification, apnDevice); + }); - this.sender.on("disconnected", function() { - console.log("APNS Disconnected"); - }); + conn.on('timeout', () => { + console.log('APNS Connection %d Timeout', conn.index); + }); - this.sender.on("socketError", console.error); + conn.on('disconnected', () => { + console.log('APNS Connection %d Disconnected', conn.index); + }); - this.sender.on("transmitted", function(notification, device) { - console.log("APNS Notification transmitted to:" + device.token.toString("hex")); + conn.on('socketError', () => { + console.log('APNS Connection %d Socket Error', conn.index); + }); + + conn.on('transmitted', function(notification, device) { + console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); + }); + + this.conns.push(conn); + } + // Sort the conn based on priority ascending, high pri first + this.conns.sort((s1, s2) => { + return s1.priority - s2.priority; }); + // Set index of conns + for (let index = 0; index < this.conns.length; index++) { + this.conns[index].index = index; + } } /** @@ -51,23 +91,84 @@ APNS.prototype.send = function(data, devices) { let coreData = data.data; let expirationTime = data['expiration_time']; let notification = generateNotification(coreData, expirationTime); - let deviceTokens = []; for (let device of devices) { - deviceTokens.push(device.deviceToken); + let qualifiedConnIndexs = chooseConns(this.conns, device); + // We can not find a valid conn, just ignore this device + if (qualifiedConnIndexs.length == 0) { + continue; + } + let conn = this.conns[qualifiedConnIndexs[0]]; + let apnDevice = new apn.Device(device.deviceToken); + apnDevice.connIndex = qualifiedConnIndexs[0]; + // Add additional appIdentifier info to apn device instance + if (device.appIdentifier) { + apnDevice.appIdentifier = device.appIdentifier; + } + conn.pushNotification(notification, apnDevice); } - this.sender.pushNotification(notification, deviceTokens); - // TODO: pushNotification will push the notification to apn's queue. - // We do not handle error in V1, we just relies apn to auto retry and send the - // notifications. return Parse.Promise.as(); } +function handleTransmissionError(conns, errCode, notification, apnDevice) { + console.error('APNS Notification caused error: ' + errCode + ' for device ', apnDevice, notification); + // This means the error notification is not in the cache anymore or the recepient is missing, + // we just ignore this case + if (!notification || !apnDevice) { + return + } + + // If currentConn can not send the push notification, we try to use the next available conn. + // Since conns is sorted by priority, the next conn means the next low pri conn. + // If there is no conn available, we give up on sending the notification to that device. + let qualifiedConnIndexs = chooseConns(conns, apnDevice); + let currentConnIndex = apnDevice.connIndex; + + let newConnIndex = -1; + // Find the next element of currentConnIndex in qualifiedConnIndexs + for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { + if (qualifiedConnIndexs[index] === currentConnIndex) { + newConnIndex = qualifiedConnIndexs[index + 1]; + break; + } + } + // There is no more available conns, we give up in this case + if (newConnIndex < 0 || newConnIndex >= conns.length) { + console.log('APNS can not find vaild connection for %j', apnDevice.token); + return; + } + + let newConn = conns[newConnIndex]; + // Update device conn info + apnDevice.connIndex = newConnIndex; + // Use the new conn to send the notification + newConn.pushNotification(notification, apnDevice); +} + +function chooseConns(conns, device) { + // If device does not have appIdentifier, all conns maybe proper connections. + // Otherwise we try to match the appIdentifier with bundleId + let qualifiedConns = []; + for (let index = 0; index < conns.length; index++) { + let conn = conns[index]; + // If the device we need to send to does not have + // appIdentifier, any conn could be a qualified connection + if (!device.appIdentifier || device.appIdentifier === '') { + qualifiedConns.push(index); + continue; + } + if (device.appIdentifier === conn.bundleId) { + qualifiedConns.push(index); + } + } + return qualifiedConns; +} + /** * Generate the apns notification from the data we get from api request. * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -let generateNotification = function(coreData, expirationTime) { +function generateNotification(coreData, expirationTime) { let notification = new apn.notification(); let payload = {}; for (let key in coreData) { @@ -101,5 +202,7 @@ let generateNotification = function(coreData, expirationTime) { if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; + APNS.chooseConns = chooseConns; + APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 43987bc59e..1ae1647f92 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -9,12 +9,7 @@ const APNS = require('../../APNS'); function ParsePushAdapter(pushConfig) { this.validPushTypes = ['ios', 'android']; - this.senders = {}; - - // Initialize senders - for (let validPushType of this.validPushTypes) { - this.senders[validPushType] = []; - } + this.senderMap = {}; pushConfig = pushConfig || {}; let pushTypes = Object.keys(pushConfig); @@ -23,47 +18,17 @@ function ParsePushAdapter(pushConfig) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push to ' + pushTypes + ' is not supported'); } - - let typePushConfig = pushConfig[pushType]; - let senderArgs = []; - // Since for ios, there maybe multiple cert/key pairs, - // typePushConfig can be an array. - if (Array.isArray(typePushConfig)) { - senderArgs = senderArgs.concat(typePushConfig); - } else if (typeof typePushConfig === 'object') { - senderArgs.push(typePushConfig); - } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Push Configuration is invalid'); - } - for (let senderArg of senderArgs) { - let sender; - switch (pushType) { - case 'ios': - sender = new APNS(senderArg); - break; - case 'android': - sender = new GCM(senderArg); - break; - } - this.senders[pushType].push(sender); + switch (pushType) { + case 'ios': + this.senderMap[pushType] = new APNS(pushConfig[pushType]); + break; + case 'android': + this.senderMap[pushType] = new GCM(pushConfig[pushType]); + break; } } } -/** - * Get an array of push senders based on the push type. - * @param {String} The push type - * @returns {Array|Undefined} An array of push senders - */ -ParsePushAdapter.prototype.getPushSenders = function(pushType) { - if (!this.senders[pushType]) { - console.log('No push sender for push type %s', pushType); - return []; - } - return this.senders[pushType]; -} - /** * Get an array of valid push types. * @returns {Array} An array of valid push types @@ -76,24 +41,18 @@ ParsePushAdapter.prototype.send = function(data, installations) { let deviceMap = classifyInstallation(installations, this.validPushTypes); let sendPromises = []; for (let pushType in deviceMap) { - let senders = this.getPushSenders(pushType); - // Since ios have dev/prod cert, a push type may have multiple senders - for (let sender of senders) { - let devices = deviceMap[pushType]; - if (!sender || devices.length == 0) { - continue; - } - // For android, we can only have 1000 recepients per send - let chunkDevices = sliceDevices(pushType, devices, GCM.GCMRegistrationTokensMax); - for (let chunkDevice of chunkDevices) { - sendPromises.push(sender.send(data, chunkDevice)); - } + let sender = this.senderMap[pushType]; + if (!sender) { + console.log('Can not find sender for push type %s, %j', pushType, data); + continue; } + let devices = deviceMap[pushType]; + sendPromises.push(sender.send(data, devices)); } return Parse.Promise.when(sendPromises); } -/** +/**g * Classify the device token of installations based on its device type. * @param {Object} installations An array of installations * @param {Array} validPushTypes An array of valid push types(string) @@ -113,7 +72,8 @@ function classifyInstallation(installations, validPushTypes) { let pushType = installation.deviceType; if (deviceMap[pushType]) { deviceMap[pushType].push({ - deviceToken: installation.deviceToken + deviceToken: installation.deviceToken, + appIdentifier: installation.appIdentifier }); } else { console.log('Unknown push type from installation %j', installation); @@ -122,26 +82,7 @@ function classifyInstallation(installations, validPushTypes) { return deviceMap; } -/** - * Slice a list of devices to several list of devices with fixed chunk size. - * @param {String} pushType The push type of the given device tokens - * @param {Array} devices An array of devices - * @param {Number} chunkSize The size of the a chunk - * @returns {Array} An array which contaisn several arries of devices with fixed chunk size - */ -function sliceDevices(pushType, devices, chunkSize) { - if (pushType !== 'android') { - return [devices]; - } - let chunkDevices = []; - while (devices.length > 0) { - chunkDevices.push(devices.splice(0, chunkSize)); - } - return chunkDevices; -} - if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { ParsePushAdapter.classifyInstallation = classifyInstallation; - ParsePushAdapter.sliceDevices = sliceDevices; } module.exports = ParsePushAdapter; diff --git a/src/GCM.js b/src/GCM.js index 9dfe1b06b7..be09f222d5 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -8,6 +8,10 @@ const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks const GCMRegistrationTokensMax = 1000; function GCM(args) { + if (typeof args !== 'object' || !args.apiKey) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'GCM Configuration is invalid'); + } this.sender = new gcm.Sender(args.apiKey); } @@ -18,10 +22,6 @@ function GCM(args) { * @returns {Object} A promise which is resolved after we get results from gcm */ GCM.prototype.send = function(data, devices) { - if (devices.length >= GCMRegistrationTokensMax) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Too many registration tokens for a GCM request.'); - } let pushId = randomstring.generate({ length: 10, charset: 'alphanumeric' @@ -37,21 +37,30 @@ GCM.prototype.send = function(data, devices) { let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); // Make and send gcm request let message = new gcm.Message(gcmPayload); - let promise = new Parse.Promise(); - let registrationTokens = [] - for (let device of devices) { - registrationTokens.push(device.deviceToken); - } - this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { - // TODO: Use the response from gcm to generate and save push report - // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation - console.log('GCM request and response %j', { - request: message, - response: response + + let sendPromises = []; + // For android, we can only have 1000 recepients per send, so we need to slice devices to + // chunk if necessary + let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax); + for (let chunkDevice of chunkDevices) { + let sendPromise = new Parse.Promise(); + let registrationTokens = [] + for (let device of chunkDevice) { + registrationTokens.push(device.deviceToken); + } + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { + // TODO: Use the response from gcm to generate and save push report + // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + console.log('GCM request and response %j', { + request: message, + response: response + }); + sendPromise.resolve(); }); - promise.resolve(); - }); - return promise; + sendPromises.push(sendPromise); + } + + return Parse.Promise.when(sendPromises); } /** @@ -62,7 +71,7 @@ GCM.prototype.send = function(data, devices) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { +function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) { let payloadData = { 'time': new Date(timeStamp).toISOString(), 'push_id': pushId, @@ -86,9 +95,22 @@ let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } -GCM.GCMRegistrationTokensMax = GCMRegistrationTokensMax; +/** + * Slice a list of devices to several list of devices with fixed chunk size. + * @param {Array} devices An array of devices + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of devices with fixed chunk size + */ +function sliceDevices(devices, chunkSize) { + let chunkDevices = []; + while (devices.length > 0) { + chunkDevices.push(devices.splice(0, chunkSize)); + } + return chunkDevices; +} if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; + GCM.sliceDevices = sliceDevices; } module.exports = GCM;