Merge pull request #1092 from ParsePlatform/wangmengyan.add_livequery_support

Add LiveQuery to parse server
This commit is contained in:
Fosco Marotto
2016-03-18 11:51:39 -08:00
33 changed files with 3580 additions and 48 deletions

View File

@@ -29,19 +29,24 @@
"deepcopy": "^0.6.1",
"express": "^4.13.4",
"gcloud": "^0.28.0",
"lru-cache": "^4.0.0",
"mailgun-js": "^0.7.7",
"mime": "^1.3.4",
"mongodb": "~2.1.0",
"multer": "^1.1.0",
"node-gcm": "^0.14.0",
"parse": "^1.7.0",
"redis": "^2.5.0-1",
"request": "^2.65.0",
"winston": "^2.1.1"
"tv4": "^1.2.7",
"winston": "^2.1.1",
"ws": "^1.0.1"
},
"devDependencies": {
"babel-cli": "^6.5.1",
"babel-core": "^6.5.1",
"babel-istanbul": "^0.6.0",
"babel-plugin-syntax-flow": "^6.5.0",
"babel-plugin-transform-flow-strip-types": "^6.5.0",
"babel-preset-es2015": "^6.5.0",
"babel-preset-stage-0": "^6.5.0",

290
spec/Client.spec.js Normal file
View File

@@ -0,0 +1,290 @@
var Client = require('../src/LiveQuery/Client').Client;
var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket;
describe('Client', function() {
it('can be initialized', function() {
var parseWebSocket = new ParseWebSocket({});
var client = new Client(1, parseWebSocket);
expect(client.id).toBe(1);
expect(client.parseWebSocket).toBe(parseWebSocket);
expect(client.subscriptionInfos.size).toBe(0);
});
it('can push response', function() {
var parseWebSocket = {
send: jasmine.createSpy('send')
};
Client.pushResponse(parseWebSocket, 'message');
expect(parseWebSocket.send).toHaveBeenCalledWith('message');
});
it('can push error', function() {
var parseWebSocket = {
send: jasmine.createSpy('send')
};
Client.pushError(parseWebSocket, 1, 'error', true);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('error');
expect(messageJSON.error).toBe('error');
expect(messageJSON.code).toBe(1);
expect(messageJSON.reconnect).toBe(true);
});
it('can add subscription information', function() {
var subscription = {};
var fields = ['test'];
var subscriptionInfo = {
subscription: subscription,
fields: fields
}
var client = new Client(1, {});
client.addSubscriptionInfo(1, subscriptionInfo);
expect(client.subscriptionInfos.size).toBe(1);
expect(client.subscriptionInfos.get(1)).toBe(subscriptionInfo);
});
it('can get subscription information', function() {
var subscription = {};
var fields = ['test'];
var subscriptionInfo = {
subscription: subscription,
fields: fields
}
var client = new Client(1, {});
client.addSubscriptionInfo(1, subscriptionInfo);
var subscriptionInfoAgain = client.getSubscriptionInfo(1);
expect(subscriptionInfoAgain).toBe(subscriptionInfo);
});
it('can delete subscription information', function() {
var subscription = {};
var fields = ['test'];
var subscriptionInfo = {
subscription: subscription,
fields: fields
}
var client = new Client(1, {});
client.addSubscriptionInfo(1, subscriptionInfo);
client.deleteSubscriptionInfo(1);
expect(client.subscriptionInfos.size).toBe(0);
});
it('can generate ParseObject JSON with null selected field', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
};
var client = new Client(1, {});
expect(client._toJSONWithFields(parseObjectJSON, null)).toBe(parseObjectJSON);
});
it('can generate ParseObject JSON with undefined selected field', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
};
var client = new Client(1, {});
expect(client._toJSONWithFields(parseObjectJSON, undefined)).toBe(parseObjectJSON);
});
it('can generate ParseObject JSON with selected fields', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var client = new Client(1, {});
expect(client._toJSONWithFields(parseObjectJSON, ['test'])).toEqual({
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
});
});
it('can generate ParseObject JSON with nonexistent selected fields', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var client = new Client(1, {});
var limitedParseObject = client._toJSONWithFields(parseObjectJSON, ['name']);
expect(limitedParseObject).toEqual({
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
});
expect('name' in limitedParseObject).toBe(false);
});
it('can push connect response', function() {
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushConnect();
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('connected');
expect(messageJSON.clientId).toBe(1);
});
it('can push subscribe response', function() {
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushSubscribe(2);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('subscribed');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
});
it('can push unsubscribe response', function() {
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushUnsubscribe(2);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('unsubscribed');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
});
it('can push create response', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushCreate(2, parseObjectJSON);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('create');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
expect(messageJSON.object).toEqual(parseObjectJSON);
});
it('can push enter response', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushEnter(2, parseObjectJSON);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('enter');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
expect(messageJSON.object).toEqual(parseObjectJSON);
});
it('can push update response', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushUpdate(2, parseObjectJSON);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('update');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
expect(messageJSON.object).toEqual(parseObjectJSON);
});
it('can push leave response', function() {
var parseObjectJSON = {
key : 'value',
className: 'test',
objectId: 'test',
updatedAt: '2015-12-07T21:27:13.746Z',
createdAt: '2015-12-07T21:27:13.746Z',
ACL: 'test',
test: 'test'
};
var parseWebSocket = {
send: jasmine.createSpy('send')
};
var client = new Client(1, parseWebSocket);
client.pushLeave(2, parseObjectJSON);
var lastCall = parseWebSocket.send.calls.first();
var messageJSON = JSON.parse(lastCall.args[0]);
expect(messageJSON.op).toBe('leave');
expect(messageJSON.clientId).toBe(1);
expect(messageJSON.requestId).toBe(2);
expect(messageJSON.object).toEqual(parseObjectJSON);
});
});

View File

@@ -0,0 +1,44 @@
var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub;
describe('EventEmitterPubSub', function() {
it('can publish and subscribe', function() {
var publisher = EventEmitterPubSub.createPublisher();
var subscriber = EventEmitterPubSub.createSubscriber();
subscriber.subscribe('testChannel');
// Register mock checked for subscriber
var isChecked = false;
subscriber.on('message', function(channel, message) {
isChecked = true;
expect(channel).toBe('testChannel');
expect(message).toBe('testMessage');
});
publisher.publish('testChannel', 'testMessage');
// Make sure the callback is checked
expect(isChecked).toBe(true);
});
it('can unsubscribe', function() {
var publisher = EventEmitterPubSub.createPublisher();
var subscriber = EventEmitterPubSub.createSubscriber();
subscriber.subscribe('testChannel');
subscriber.unsubscribe('testChannel');
// Register mock checked for subscriber
var isCalled = false;
subscriber.on('message', function(channel, message) {
isCalled = true;
});
publisher.publish('testChannel', 'testMessage');
// Make sure the callback is not called
expect(isCalled).toBe(false);
});
it('can unsubscribe not subscribing channel', function() {
var subscriber = EventEmitterPubSub.createSubscriber();
// Make sure subscriber does not throw exception
subscriber.unsubscribe('testChannel');
});
});

View File

@@ -0,0 +1,70 @@
var ParseCloudCodePublisher = require('../src/LiveQuery/ParseCloudCodePublisher').ParseCloudCodePublisher;
var Parse = require('parse/node');
describe('ParseCloudCodePublisher', function() {
beforeEach(function(done) {
// Mock ParsePubSub
var mockParsePubSub = {
createPublisher: jasmine.createSpy('publish').and.returnValue({
publish: jasmine.createSpy('publish'),
on: jasmine.createSpy('on')
}),
createSubscriber: jasmine.createSpy('publish').and.returnValue({
subscribe: jasmine.createSpy('subscribe'),
on: jasmine.createSpy('on')
})
};
jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub);
done();
});
it('can initialize', function() {
var config = {}
var publisher = new ParseCloudCodePublisher(config);
var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub;
expect(ParsePubSub.createPublisher).toHaveBeenCalledWith(config);
});
it('can handle cloud code afterSave request', function() {
var publisher = new ParseCloudCodePublisher({});
publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage');
var request = {};
publisher.onCloudCodeAfterSave(request);
expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterSave', request);
});
it('can handle cloud code afterDelete request', function() {
var publisher = new ParseCloudCodePublisher({});
publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage');
var request = {};
publisher.onCloudCodeAfterDelete(request);
expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterDelete', request);
});
it('can handle cloud code request', function() {
var publisher = new ParseCloudCodePublisher({});
var currentParseObject = new Parse.Object('Test');
currentParseObject.set('key', 'value');
var originalParseObject = new Parse.Object('Test');
originalParseObject.set('key', 'originalValue');
var request = {
object: currentParseObject,
original: originalParseObject
};
publisher._onCloudCodeMessage('afterSave', request);
var args = publisher.parsePublisher.publish.calls.mostRecent().args;
expect(args[0]).toBe('afterSave');
var message = JSON.parse(args[1]);
expect(message.currentParseObject).toEqual(request.object._toFullJSON());
expect(message.originalParseObject).toEqual(request.original._toFullJSON());
});
afterEach(function(){
jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub');
});
});

View File

@@ -0,0 +1,962 @@
var Parse = require('parse/node');
var ParseLiveQueryServer = require('../src/LiveQuery/ParseLiveQueryServer').ParseLiveQueryServer;
// Global mock info
var queryHashValue = 'hash';
var testUserId = 'userId';
var testClassName = 'TestObject';
describe('ParseLiveQueryServer', function() {
beforeEach(function(done) {
// Mock ParseWebSocketServer
var mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer');
jasmine.mockLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer', mockParseWebSocketServer);
// Mock Client
var mockClient = function() {
this.pushConnect = jasmine.createSpy('pushConnect');
this.pushSubscribe = jasmine.createSpy('pushSubscribe');
this.pushUnsubscribe = jasmine.createSpy('pushUnsubscribe');
this.pushDelete = jasmine.createSpy('pushDelete');
this.pushCreate = jasmine.createSpy('pushCreate');
this.pushEnter = jasmine.createSpy('pushEnter');
this.pushUpdate = jasmine.createSpy('pushUpdate');
this.pushLeave = jasmine.createSpy('pushLeave');
this.addSubscriptionInfo = jasmine.createSpy('addSubscriptionInfo');
this.getSubscriptionInfo = jasmine.createSpy('getSubscriptionInfo');
this.deleteSubscriptionInfo = jasmine.createSpy('deleteSubscriptionInfo');
}
mockClient.pushError = jasmine.createSpy('pushError');
jasmine.mockLibrary('../src/LiveQuery/Client', 'Client', mockClient);
// Mock Subscription
var mockSubscriotion = function() {
this.addClientSubscription = jasmine.createSpy('addClientSubscription');
this.deleteClientSubscription = jasmine.createSpy('deleteClientSubscription');
}
jasmine.mockLibrary('../src/LiveQuery/Subscription', 'Subscription', mockSubscriotion);
// Mock queryHash
var mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue(queryHashValue);
jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'queryHash', mockQueryHash);
// Mock matchesQuery
var mockMatchesQuery = jasmine.createSpy('matchesQuery').and.returnValue(true);
jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'matchesQuery', mockMatchesQuery);
// Mock tv4
var mockValidate = function() {
return true;
}
jasmine.mockLibrary('tv4', 'validate', mockValidate);
// Mock ParsePubSub
var mockParsePubSub = {
createPublisher: function() {
return {
publish: jasmine.createSpy('publish'),
on: jasmine.createSpy('on')
}
},
createSubscriber: function() {
return {
subscribe: jasmine.createSpy('subscribe'),
on: jasmine.createSpy('on')
}
}
};
jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub);
// Make mock SessionTokenCache
var mockSessionTokenCache = function(){
this.getUserId = function(sessionToken){
if (typeof sessionToken === 'undefined') {
return Parse.Promise.as(undefined);
}
if (sessionToken === null) {
return Parse.Promise.error();
}
return Parse.Promise.as(testUserId);
};
};
jasmine.mockLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache', mockSessionTokenCache);
done();
});
it('can be initialized', function() {
var httpServer = {};
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, httpServer);
expect(parseLiveQueryServer.clientId).toBe(0);
expect(parseLiveQueryServer.clients.size).toBe(0);
expect(parseLiveQueryServer.subscriptions.size).toBe(0);
});
it('can handle connect command', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var parseWebSocket = {
clientId: -1
};
parseLiveQueryServer._validateKeys = jasmine.createSpy('validateKeys').and.returnValue(true);
parseLiveQueryServer._handleConnect(parseWebSocket);
expect(parseLiveQueryServer.clientId).toBe(1);
expect(parseWebSocket.clientId).toBe(0);
var client = parseLiveQueryServer.clients.get(0);
expect(client).not.toBeNull();
// Make sure we send connect response to the client
expect(client.pushConnect).toHaveBeenCalled();
});
it('can handle subscribe command without clientId', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var incompleteParseConn = {
};
parseLiveQueryServer._handleSubscribe(incompleteParseConn, {});
var Client = require('../src/LiveQuery/Client').Client;
expect(Client.pushError).toHaveBeenCalled();
});
it('can handle subscribe command with new query', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Handle mock subscription
var parseWebSocket = {
clientId: clientId
};
var query = {
className: 'test',
where: {
key: 'value'
},
fields: [ 'test' ]
}
var requestId = 2;
var request = {
query: query,
requestId: requestId,
sessionToken: 'sessionToken'
}
parseLiveQueryServer._handleSubscribe(parseWebSocket, request);
// Make sure we add the subscription to the server
var subscriptions = parseLiveQueryServer.subscriptions;
expect(subscriptions.size).toBe(1);
expect(subscriptions.get(query.className)).not.toBeNull();
var classSubscriptions = subscriptions.get(query.className);
expect(classSubscriptions.size).toBe(1);
expect(classSubscriptions.get('hash')).not.toBeNull();
// TODO(check subscription constructor to verify we pass the right argument)
// Make sure we add clientInfo to the subscription
var subscription = classSubscriptions.get('hash');
expect(subscription.addClientSubscription).toHaveBeenCalledWith(clientId, requestId);
// Make sure we add subscriptionInfo to the client
var args = client.addSubscriptionInfo.calls.first().args;
expect(args[0]).toBe(requestId);
expect(args[1].fields).toBe(query.fields);
expect(args[1].sessionToken).toBe(request.sessionToken);
// Make sure we send subscribe response to the client
expect(client.pushSubscribe).toHaveBeenCalledWith(requestId);
});
it('can handle subscribe command with existing query', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Add two mock clients
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
var clientIdAgain = 2;
var clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain);
// Add subscription for mock client 1
var parseWebSocket = {
clientId: clientId
};
var requestId = 2;
var query = {
className: 'test',
where: {
key: 'value'
},
fields: [ 'test' ]
}
addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query);
// Add subscription for mock client 2
var parseWebSocketAgain = {
clientId: clientIdAgain
};
var queryAgain = {
className: 'test',
where: {
key: 'value'
},
fields: [ 'testAgain' ]
}
var requestIdAgain = 1;
addMockSubscription(parseLiveQueryServer, clientIdAgain, requestIdAgain, parseWebSocketAgain, queryAgain);
// Make sure we only have one subscription
var subscriptions = parseLiveQueryServer.subscriptions;
expect(subscriptions.size).toBe(1);
expect(subscriptions.get(query.className)).not.toBeNull();
var classSubscriptions = subscriptions.get(query.className);
expect(classSubscriptions.size).toBe(1);
expect(classSubscriptions.get('hash')).not.toBeNull();
// Make sure we add clientInfo to the subscription
var subscription = classSubscriptions.get('hash');
// Make sure client 2 info has been added
var args = subscription.addClientSubscription.calls.mostRecent().args;
expect(args).toEqual([clientIdAgain, requestIdAgain]);
// Make sure we add subscriptionInfo to the client 2
args = clientAgain.addSubscriptionInfo.calls.mostRecent().args;
expect(args[0]).toBe(requestIdAgain);
expect(args[1].fields).toBe(queryAgain.fields);
});
it('can handle unsubscribe command without clientId', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var incompleteParseConn = {
};
parseLiveQueryServer._handleUnsubscribe(incompleteParseConn, {});
var Client = require('../src/LiveQuery/Client').Client;
expect(Client.pushError).toHaveBeenCalled();
});
it('can handle unsubscribe command without not existed client', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var parseWebSocket = {
clientId: 1
};
parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {});
var Client = require('../src/LiveQuery/Client').Client;
expect(Client.pushError).toHaveBeenCalled();
});
it('can handle unsubscribe command without not existed query', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Handle unsubscribe command
var parseWebSocket = {
clientId: 1
};
parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {});
var Client = require('../src/LiveQuery/Client').Client;
expect(Client.pushError).toHaveBeenCalled();
});
it('can handle unsubscribe command', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add subscription for mock client
var parseWebSocket = {
clientId: 1
};
var requestId = 2;
var subscription = addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket);
// Mock client.getSubscriptionInfo
var subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent().args[1];
client.getSubscriptionInfo = function() {
return subscriptionInfo;
};
// Handle unsubscribe command
var requestAgain = {
requestId: requestId
};
parseLiveQueryServer._handleUnsubscribe(parseWebSocket, requestAgain);
// Make sure we delete subscription from client
expect(client.deleteSubscriptionInfo).toHaveBeenCalledWith(requestId);
// Make sure we delete client from subscription
expect(subscription.deleteClientSubscription).toHaveBeenCalledWith(clientId, requestId);
// Make sure we clear subscription in the server
var subscriptions = parseLiveQueryServer.subscriptions;
expect(subscriptions.size).toBe(0);
});
it('can set connect command message handler for a parseWebSocket', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Register mock connect/subscribe/unsubscribe handler for the server
parseLiveQueryServer._handleConnect = jasmine.createSpy('_handleSubscribe');
// Make mock parseWebsocket
var EventEmitter = require('events');
var parseWebSocket = new EventEmitter();
// Register message handlers for the parseWebSocket
parseLiveQueryServer._onConnect(parseWebSocket);
// Check connect request
var connectRequest = {
op: 'connect'
};
// Trigger message event
parseWebSocket.emit('message', connectRequest);
// Make sure _handleConnect is called
var args = parseLiveQueryServer._handleConnect.calls.mostRecent().args;
expect(args[0]).toBe(parseWebSocket);
});
it('can set subscribe command message handler for a parseWebSocket', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Register mock connect/subscribe/unsubscribe handler for the server
parseLiveQueryServer._handleSubscribe = jasmine.createSpy('_handleSubscribe');
// Make mock parseWebsocket
var EventEmitter = require('events');
var parseWebSocket = new EventEmitter();
// Register message handlers for the parseWebSocket
parseLiveQueryServer._onConnect(parseWebSocket);
// Check subscribe request
var subscribeRequest = '{"op":"subscribe"}';
// Trigger message event
parseWebSocket.emit('message', subscribeRequest);
// Make sure _handleSubscribe is called
var args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args;
expect(args[0]).toBe(parseWebSocket);
expect(JSON.stringify(args[1])).toBe(subscribeRequest);
});
it('can set unsubscribe command message handler for a parseWebSocket', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Register mock connect/subscribe/unsubscribe handler for the server
parseLiveQueryServer._handleUnsubscribe = jasmine.createSpy('_handleSubscribe');
// Make mock parseWebsocket
var EventEmitter = require('events');
var parseWebSocket = new EventEmitter();
// Register message handlers for the parseWebSocket
parseLiveQueryServer._onConnect(parseWebSocket);
// Check unsubscribe request
var unsubscribeRequest = '{"op":"unsubscribe"}';
// Trigger message event
parseWebSocket.emit('message', unsubscribeRequest);
// Make sure _handleUnsubscribe is called
var args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args;
expect(args[0]).toBe(parseWebSocket);
expect(JSON.stringify(args[1])).toBe(unsubscribeRequest);
});
it('can set unknown command message handler for a parseWebSocket', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock parseWebsocket
var EventEmitter = require('events');
var parseWebSocket = new EventEmitter();
// Register message handlers for the parseWebSocket
parseLiveQueryServer._onConnect(parseWebSocket);
// Check unknown request
var unknownRequest = '{"op":"unknown"}';
// Trigger message event
parseWebSocket.emit('message', unknownRequest);
var Client = require('../src/LiveQuery/Client').Client;
expect(Client.pushError).toHaveBeenCalled();
});
it('can set disconnect command message handler for a parseWebSocket which has not registered to the server', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var EventEmitter = require('events');
var parseWebSocket = new EventEmitter();
parseWebSocket.clientId = 1;
// Register message handlers for the parseWebSocket
parseLiveQueryServer._onConnect(parseWebSocket);
// Make sure we do not crash
// Trigger disconnect event
parseWebSocket.emit('disconnect');
});
// TODO: Test server can set disconnect command message handler for a parseWebSocket
it('has no subscription and can handle object delete command', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make deletedParseObject
var parseObject = new Parse.Object(testClassName);
parseObject._finishFetch({
key: 'value',
className: testClassName
});
// Make mock message
var message = {
currentParseObject: parseObject
};
// Make sure we do not crash in this case
parseLiveQueryServer._onAfterDelete(message, {});
});
it('can handle object delete command which does not match any subscription', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make deletedParseObject
var parseObject = new Parse.Object(testClassName);
parseObject._finishFetch({
key: 'value',
className: testClassName
});
// Make mock message
var message = {
currentParseObject: parseObject
};
// Add mock client
var clientId = 1;
addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
var client = parseLiveQueryServer.clients.get(clientId);
// Mock _matchesSubscription to return not matching
parseLiveQueryServer._matchesSubscription = function() {
return false;
};
parseLiveQueryServer._matchesACL = function() {
return true;
};
parseLiveQueryServer._onAfterDelete(message);
// Make sure we do not send command to client
expect(client.pushDelete).not.toHaveBeenCalled();
});
it('can handle object delete command which matches some subscriptions', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make deletedParseObject
var parseObject = new Parse.Object(testClassName);
parseObject._finishFetch({
key: 'value',
className: testClassName
});
// Make mock message
var message = {
currentParseObject: parseObject
};
// Add mock client
var clientId = 1;
addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
var client = parseLiveQueryServer.clients.get(clientId);
// Mock _matchesSubscription to return matching
parseLiveQueryServer._matchesSubscription = function() {
return true;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true);
};
parseLiveQueryServer._onAfterDelete(message);
// Make sure we send command to client, since _matchesACL is async, we have to
// wait and check
setTimeout(function() {
expect(client.pushDelete).toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('has no subscription and can handle object save command', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage();
// Make sure we do not crash in this case
parseLiveQueryServer._onAfterSave(message);
});
it('can handle object save command which does not match any subscription', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage();
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
// Mock _matchesSubscription to return not matching
parseLiveQueryServer._matchesSubscription = function() {
return false;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true)
};
// Trigger onAfterSave
parseLiveQueryServer._onAfterSave(message);
// Make sure we do not send command to client
setTimeout(function(){
expect(client.pushCreate).not.toHaveBeenCalled();
expect(client.pushEnter).not.toHaveBeenCalled();
expect(client.pushUpdate).not.toHaveBeenCalled();
expect(client.pushDelete).not.toHaveBeenCalled();
expect(client.pushLeave).not.toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('can handle object enter command which matches some subscriptions', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage(true);
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
// Mock _matchesSubscription to return matching
// In order to mimic a enter, we need original match return false
// and the current match return true
var counter = 0;
parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){
if (!parseObject) {
return false;
}
counter += 1;
return counter % 2 === 0;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true)
};
parseLiveQueryServer._onAfterSave(message);
// Make sure we send enter command to client
setTimeout(function(){
expect(client.pushCreate).not.toHaveBeenCalled();
expect(client.pushEnter).toHaveBeenCalled();
expect(client.pushUpdate).not.toHaveBeenCalled();
expect(client.pushDelete).not.toHaveBeenCalled();
expect(client.pushLeave).not.toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('can handle object update command which matches some subscriptions', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage(true);
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
// Mock _matchesSubscription to return matching
parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){
if (!parseObject) {
return false;
}
return true;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true)
};
parseLiveQueryServer._onAfterSave(message);
// Make sure we send update command to client
setTimeout(function(){
expect(client.pushCreate).not.toHaveBeenCalled();
expect(client.pushEnter).not.toHaveBeenCalled();
expect(client.pushUpdate).toHaveBeenCalled();
expect(client.pushDelete).not.toHaveBeenCalled();
expect(client.pushLeave).not.toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('can handle object leave command which matches some subscriptions', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage(true);
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
// Mock _matchesSubscription to return matching
// In order to mimic a leave, we need original match return true
// and the current match return false
var counter = 0;
parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){
if (!parseObject) {
return false;
}
counter += 1;
return counter % 2 !== 0;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true)
};
parseLiveQueryServer._onAfterSave(message);
// Make sure we send leave command to client
setTimeout(function(){
expect(client.pushCreate).not.toHaveBeenCalled();
expect(client.pushEnter).not.toHaveBeenCalled();
expect(client.pushUpdate).not.toHaveBeenCalled();
expect(client.pushDelete).not.toHaveBeenCalled();
expect(client.pushLeave).toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('can handle object create command which matches some subscriptions', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request message
var message = generateMockMessage();
// Add mock client
var clientId = 1;
var client = addMockClient(parseLiveQueryServer, clientId);
// Add mock subscription
var requestId = 2;
addMockSubscription(parseLiveQueryServer, clientId, requestId);
// Mock _matchesSubscription to return matching
parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){
if (!parseObject) {
return false;
}
return true;
};
parseLiveQueryServer._matchesACL = function() {
return Parse.Promise.as(true)
};
parseLiveQueryServer._onAfterSave(message);
// Make sure we send create command to client
setTimeout(function(){
expect(client.pushCreate).toHaveBeenCalled();
expect(client.pushEnter).not.toHaveBeenCalled();
expect(client.pushUpdate).not.toHaveBeenCalled();
expect(client.pushDelete).not.toHaveBeenCalled();
expect(client.pushLeave).not.toHaveBeenCalled();
done();
}, jasmine.ASYNC_TEST_WAIT_TIME);
});
it('can match subscription for null or undefined parse object', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock subscription
var subscription = {
match: jasmine.createSpy('match')
}
expect(parseLiveQueryServer._matchesSubscription(null, subscription)).toBe(false);
expect(parseLiveQueryServer._matchesSubscription(undefined, subscription)).toBe(false);
// Make sure subscription.match is not called
expect(subscription.match).not.toHaveBeenCalled();
});
it('can match subscription', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock subscription
var subscription = {
query: {}
}
var parseObject = {};
expect(parseLiveQueryServer._matchesSubscription(parseObject, subscription)).toBe(true);
// Make sure matchesQuery is called
var matchesQuery = require('../src/LiveQuery/QueryTools').matchesQuery;
expect(matchesQuery).toHaveBeenCalledWith(parseObject, subscription.query);
});
it('can inflate parse object', function() {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
// Make mock request
var objectJSON = {
"className":"testClassName",
"createdAt":"2015-12-22T01:51:12.955Z",
"key":"value",
"objectId":"BfwxBCz6yW",
"updatedAt":"2016-01-05T00:46:45.659Z"
};
var originalObjectJSON = {
"className":"testClassName",
"createdAt":"2015-12-22T01:51:12.955Z",
"key":"originalValue",
"objectId":"BfwxBCz6yW",
"updatedAt":"2016-01-05T00:46:45.659Z"
};
var message = {
currentParseObject: objectJSON,
originalParseObject: originalObjectJSON
};
// Inflate the object
parseLiveQueryServer._inflateParseObject(message);
// Verify object
var object = message.currentParseObject;
expect(object instanceof Parse.Object).toBeTruthy();
expect(object.get('key')).toEqual('value');
expect(object.className).toEqual('testClassName');
expect(object.id).toBe('BfwxBCz6yW');
expect(object.createdAt).not.toBeUndefined();
expect(object.updatedAt).not.toBeUndefined();
// Verify original object
var originalObject = message.originalParseObject;
expect(originalObject instanceof Parse.Object).toBeTruthy();
expect(originalObject.get('key')).toEqual('originalValue');
expect(originalObject.className).toEqual('testClassName');
expect(originalObject.id).toBe('BfwxBCz6yW');
expect(originalObject.createdAt).not.toBeUndefined();
expect(originalObject.updatedAt).not.toBeUndefined();
});
it('can match undefined ACL', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var client = {};
var requestId = 0;
parseLiveQueryServer._matchesACL(undefined, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(true);
done();
});
});
it('can match ACL with none exist requestId', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
var client = {
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue(undefined)
};
var requestId = 0;
var isChecked = false;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(false);
done();
});
});
it('can match ACL with public read access', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setPublicReadAccess(true);
var client = {
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: 'sessionToken'
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(true);
done();
});
});
it('can match ACL with valid subscription sessionToken', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
var client = {
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: 'sessionToken'
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(true);
done();
});
});
it('can match ACL with valid client sessionToken', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return false when sessionToken is undefined
var client = {
sessionToken: 'sessionToken',
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: undefined
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(true);
done();
});
});
it('can match ACL with invalid subscription and client sessionToken', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return false when sessionToken is undefined
var client = {
sessionToken: undefined,
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: undefined
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(false);
done();
});
});
it('can match ACL with subscription sessionToken checking error', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return error when sessionToken is null, this is just
// the behaviour of our mock sessionTokenCache, not real sessionTokenCache
var client = {
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: null
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(false);
done();
});
});
it('can match ACL with client sessionToken checking error', function(done) {
var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {});
var acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return error when sessionToken is null
var client = {
sessionToken: null,
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
sessionToken: null
})
};
var requestId = 0;
parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) {
expect(isMatched).toBe(false);
done();
});
});
it('can validate key when valid key is provided', function() {
var parseLiveQueryServer = new ParseLiveQueryServer({}, {
keyPairs: {
clientKey: 'test'
}
});
var request = {
clientKey: 'test'
}
expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy();
});
it('can validate key when invalid key is provided', function() {
var parseLiveQueryServer = new ParseLiveQueryServer({}, {
keyPairs: {
clientKey: 'test'
}
});
var request = {
clientKey: 'error'
}
expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy();
});
it('can validate key when key is not provided', function() {
var parseLiveQueryServer = new ParseLiveQueryServer({}, {
keyPairs: {
clientKey: 'test'
}
});
var request = {
}
expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy();
});
it('can validate key when validKerPairs is empty', function() {
var parseLiveQueryServer = new ParseLiveQueryServer({}, {});
var request = {
}
expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy();
});
afterEach(function(){
jasmine.restoreLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer');
jasmine.restoreLibrary('../src/LiveQuery/Client', 'Client');
jasmine.restoreLibrary('../src/LiveQuery/Subscription', 'Subscription');
jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'queryHash');
jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'matchesQuery');
jasmine.restoreLibrary('tv4', 'validate');
jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub');
jasmine.restoreLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache');
});
// Helper functions to add mock client and subscription to a liveQueryServer
function addMockClient(parseLiveQueryServer, clientId) {
var Client = require('../src/LiveQuery/Client').Client;
var client = new Client(clientId, {});
parseLiveQueryServer.clients.set(clientId, client);
return client;
}
function addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query) {
// If parseWebSocket is null, we use the default one
if (!parseWebSocket) {
var EventEmitter = require('events');
parseWebSocket = new EventEmitter();
}
parseWebSocket.clientId = clientId;
// If query is null, we use the default one
if (!query) {
query = {
className: testClassName,
where: {
key: 'value'
},
fields: [ 'test' ]
};
}
var request = {
query: query,
requestId: requestId,
sessionToken: 'sessionToken'
};
parseLiveQueryServer._handleSubscribe(parseWebSocket, request);
// Make mock subscription
var subscription = parseLiveQueryServer.subscriptions.get(query.className).get(queryHashValue);
subscription.hasSubscribingClient = function() {
return false;
}
subscription.className = query.className;
subscription.hash = queryHashValue;
if (subscription.clientRequestIds && subscription.clientRequestIds.has(clientId)) {
subscription.clientRequestIds.get(clientId).push(requestId);
} else {
subscription.clientRequestIds = new Map([[clientId, [requestId]]]);
}
return subscription;
}
// Helper functiosn to generate request message
function generateMockMessage(hasOriginalParseObject) {
var parseObject = new Parse.Object(testClassName);
parseObject._finishFetch({
key: 'value',
className: testClassName
});
var message = {
currentParseObject: parseObject
};
if (hasOriginalParseObject) {
var originalParseObject = new Parse.Object(testClassName);
originalParseObject._finishFetch({
key: 'originalValue',
className: testClassName
});
message.originalParseObject = originalParseObject;
}
return message;
}
});

65
spec/ParsePubSub.spec.js Normal file
View File

@@ -0,0 +1,65 @@
var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub;
describe('ParsePubSub', function() {
beforeEach(function(done) {
// Mock RedisPubSub
var mockRedisPubSub = {
createPublisher: jasmine.createSpy('createPublisherRedis'),
createSubscriber: jasmine.createSpy('createSubscriberRedis')
};
jasmine.mockLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub', mockRedisPubSub);
// Mock EventEmitterPubSub
var mockEventEmitterPubSub = {
createPublisher: jasmine.createSpy('createPublisherEventEmitter'),
createSubscriber: jasmine.createSpy('createSubscriberEventEmitter')
};
jasmine.mockLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub', mockEventEmitterPubSub);
done();
});
it('can create redis publisher', function() {
var publisher = ParsePubSub.createPublisher({
redisURL: 'redisURL'
});
var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub;
var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub;
expect(RedisPubSub.createPublisher).toHaveBeenCalledWith('redisURL');
expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled();
});
it('can create event emitter publisher', function() {
var publisher = ParsePubSub.createPublisher({});
var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub;
var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub;
expect(RedisPubSub.createPublisher).not.toHaveBeenCalled();
expect(EventEmitterPubSub.createPublisher).toHaveBeenCalled();
});
it('can create redis subscriber', function() {
var subscriber = ParsePubSub.createSubscriber({
redisURL: 'redisURL'
});
var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub;
var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub;
expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith('redisURL');
expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled();
});
it('can create event emitter subscriber', function() {
var subscriptionInfos = ParsePubSub.createSubscriber({});
var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub;
var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub;
expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled();
expect(EventEmitterPubSub.createSubscriber).toHaveBeenCalled();
});
afterEach(function(){
jasmine.restoreLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub');
jasmine.restoreLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub');
});
});

View File

@@ -0,0 +1,43 @@
var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket;
describe('ParseWebSocket', function() {
it('can be initialized', function() {
var ws = {};
var parseWebSocket = new ParseWebSocket(ws);
expect(parseWebSocket.ws).toBe(ws);
});
it('can handle events defined in typeMap', function() {
var ws = {
on: jasmine.createSpy('on')
};
var callback = {};
var parseWebSocket = new ParseWebSocket(ws);
parseWebSocket.on('disconnect', callback);
expect(parseWebSocket.ws.on).toHaveBeenCalledWith('close', callback);
});
it('can handle events which are not defined in typeMap', function() {
var ws = {
on: jasmine.createSpy('on')
};
var callback = {};
var parseWebSocket = new ParseWebSocket(ws);
parseWebSocket.on('open', callback);
expect(parseWebSocket.ws.on).toHaveBeenCalledWith('open', callback);
});
it('can send a message', function() {
var ws = {
send: jasmine.createSpy('send')
};
var parseWebSocket = new ParseWebSocket(ws);
parseWebSocket.send('message')
expect(parseWebSocket.ws.send).toHaveBeenCalledWith('message');
});
});

View File

@@ -0,0 +1,37 @@
var ParseWebSocketServer = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocketServer;
describe('ParseWebSocketServer', function() {
beforeEach(function(done) {
// Mock ws server
var EventEmitter = require('events');
var mockServer = function() {
return new EventEmitter();
};
jasmine.mockLibrary('ws', 'Server', mockServer);
done();
});
it('can handle connect event when ws is open', function(done) {
var onConnectCallback = jasmine.createSpy('onConnectCallback');
var parseWebSocketServer = new ParseWebSocketServer({}, onConnectCallback, 5).server;
var ws = {
readyState: 0,
OPEN: 0,
ping: jasmine.createSpy('ping')
};
parseWebSocketServer.emit('connection', ws);
// Make sure callback is called
expect(onConnectCallback).toHaveBeenCalled();
// Make sure we ping to the client
setTimeout(function() {
expect(ws.ping).toHaveBeenCalled();
done();
}, 10)
});
afterEach(function(){
jasmine.restoreLibrary('ws', 'Server');
});
});

384
spec/QueryTools.spec.js Normal file
View File

@@ -0,0 +1,384 @@
var Parse = require('parse/node');
var Id = require('../src/LiveQuery/Id');
var QueryTools = require('../src/LiveQuery/QueryTools');
var queryHash = QueryTools.queryHash;
var matchesQuery = QueryTools.matchesQuery;
var Item = Parse.Object.extend('Item');
describe('queryHash', function() {
it('should always hash a query to the same string', function() {
var q = new Parse.Query(Item);
q.equalTo('field', 'value');
q.exists('name');
q.ascending('createdAt');
q.limit(10);
var firstHash = queryHash(q);
var secondHash = queryHash(q);
expect(firstHash).toBe(secondHash);
});
it('should return equivalent hashes for equivalent queries', function() {
var q1 = new Parse.Query(Item);
q1.equalTo('field', 'value');
q1.exists('name');
q1.lessThan('age', 30);
q1.greaterThan('age', 3);
q1.ascending('createdAt');
q1.include(['name', 'age']);
q1.limit(10);
var q2 = new Parse.Query(Item);
q2.limit(10);
q2.greaterThan('age', 3);
q2.lessThan('age', 30);
q2.include(['name', 'age']);
q2.ascending('createdAt');
q2.exists('name');
q2.equalTo('field', 'value');
var firstHash = queryHash(q1);
var secondHash = queryHash(q2);
expect(firstHash).toBe(secondHash);
q1.containedIn('fruit', ['apple', 'banana', 'cherry']);
firstHash = queryHash(q1);
expect(firstHash).not.toBe(secondHash);
q2.containedIn('fruit', ['banana', 'cherry', 'apple']);
secondHash = queryHash(q2);
expect(secondHash).toBe(firstHash);
q1.containedIn('fruit', ['coconut']);
firstHash = queryHash(q1);
expect(firstHash).not.toBe(secondHash);
q1 = new Parse.Query(Item);
q1.equalTo('field', 'value');
q1.lessThan('age', 30);
q1.exists('name');
q2 = new Parse.Query(Item);
q2.equalTo('name', 'person');
q2.equalTo('field', 'other');
firstHash = queryHash(Parse.Query.or(q1, q2));
secondHash = queryHash(Parse.Query.or(q2, q1));
expect(firstHash).toBe(secondHash);
});
it('should not let fields of different types appear similar', function() {
var q1 = new Parse.Query(Item);
q1.lessThan('age', 30);
var q2 = new Parse.Query(Item);
q2.equalTo('age', '{$lt:30}');
expect(queryHash(q1)).not.toBe(queryHash(q2));
q1 = new Parse.Query(Item);
q1.equalTo('age', 15);
q2.equalTo('age', '15');
expect(queryHash(q1)).not.toBe(queryHash(q2));
});
});
describe('matchesQuery', function() {
it('matches blanket queries', function() {
var obj = {
id: new Id('Klass', 'O1'),
value: 12
};
var q = new Parse.Query('Klass');
expect(matchesQuery(obj, q)).toBe(true);
obj.id = new Id('Other', 'O1');
expect(matchesQuery(obj, q)).toBe(false);
});
it('matches existence queries', function() {
var obj = {
id: new Id('Item', 'O1'),
count: 15
};
var q = new Parse.Query('Item');
q.exists('count');
expect(matchesQuery(obj, q)).toBe(true);
q.exists('name');
expect(matchesQuery(obj, q)).toBe(false);
});
it('matches on equality queries', function() {
var day = new Date();
var location = new Parse.GeoPoint({
latitude: 37.484815,
longitude: -122.148377
});
var obj = {
id: new Id('Person', 'O1'),
score: 12,
name: 'Bill',
birthday: day,
lastLocation: location
};
var q = new Parse.Query('Person');
q.equalTo('score', 12);
expect(matchesQuery(obj, q)).toBe(true);
q = new Parse.Query('Person');
q.equalTo('name', 'Bill');
expect(matchesQuery(obj, q)).toBe(true);
q.equalTo('name', 'Jeff');
expect(matchesQuery(obj, q)).toBe(false);
q = new Parse.Query('Person');
q.containedIn('name', ['Adam', 'Ben', 'Charles']);
expect(matchesQuery(obj, q)).toBe(false);
q.containedIn('name', ['Adam', 'Bill', 'Charles']);
expect(matchesQuery(obj, q)).toBe(true);
q = new Parse.Query('Person');
q.notContainedIn('name', ['Adam', 'Bill', 'Charles']);
expect(matchesQuery(obj, q)).toBe(false);
q.notContainedIn('name', ['Adam', 'Ben', 'Charles']);
expect(matchesQuery(obj, q)).toBe(true);
q = new Parse.Query('Person');
q.equalTo('birthday', day);
expect(matchesQuery(obj, q)).toBe(true);
q.equalTo('birthday', new Date());
expect(matchesQuery(obj, q)).toBe(false);
q = new Parse.Query('Person');
q.equalTo('lastLocation', new Parse.GeoPoint({
latitude: 37.484815,
longitude: -122.148377
}));
expect(matchesQuery(obj, q)).toBe(true);
q.equalTo('lastLocation', new Parse.GeoPoint({
latitude: 37.4848,
longitude: -122.1483
}));
expect(matchesQuery(obj, q)).toBe(false);
q.equalTo('lastLocation', new Parse.GeoPoint({
latitude: 37.484815,
longitude: -122.148377
}));
q.equalTo('score', 12);
q.equalTo('name', 'Bill');
q.equalTo('birthday', day);
expect(matchesQuery(obj, q)).toBe(true);
q.equalTo('name', 'bill');
expect(matchesQuery(obj, q)).toBe(false);
var img = {
id: new Id('Image', 'I1'),
tags: ['nofilter', 'latergram', 'tbt']
};
q = new Parse.Query('Image');
q.equalTo('tags', 'selfie');
expect(matchesQuery(img, q)).toBe(false);
q.equalTo('tags', 'tbt');
expect(matchesQuery(img, q)).toBe(true);
var q2 = new Parse.Query('Image');
q2.containsAll('tags', ['latergram', 'nofilter']);
expect(matchesQuery(img, q2)).toBe(true);
q2.containsAll('tags', ['latergram', 'selfie']);
expect(matchesQuery(img, q2)).toBe(false);
var u = new Parse.User();
u.id = 'U2';
q = new Parse.Query('Image');
q.equalTo('owner', u);
img = {
className: 'Image',
objectId: 'I1',
owner: {
className: '_User',
objectId: 'U2'
}
};
expect(matchesQuery(img, q)).toBe(true);
img.owner.objectId = 'U3';
expect(matchesQuery(img, q)).toBe(false);
});
it('matches on inequalities', function() {
var player = {
id: new Id('Person', 'O1'),
score: 12,
name: 'Bill',
birthday: new Date(1980, 2, 4),
};
var q = new Parse.Query('Person');
q.lessThan('score', 15);
expect(matchesQuery(player, q)).toBe(true);
q.lessThan('score', 10);
expect(matchesQuery(player, q)).toBe(false);
q = new Parse.Query('Person');
q.lessThanOrEqualTo('score', 15);
expect(matchesQuery(player, q)).toBe(true);
q.lessThanOrEqualTo('score', 12);
expect(matchesQuery(player, q)).toBe(true);
q.lessThanOrEqualTo('score', 10);
expect(matchesQuery(player, q)).toBe(false);
q = new Parse.Query('Person');
q.greaterThan('score', 15);
expect(matchesQuery(player, q)).toBe(false);
q.greaterThan('score', 10);
expect(matchesQuery(player, q)).toBe(true);
q = new Parse.Query('Person');
q.greaterThanOrEqualTo('score', 15);
expect(matchesQuery(player, q)).toBe(false);
q.greaterThanOrEqualTo('score', 12);
expect(matchesQuery(player, q)).toBe(true);
q.greaterThanOrEqualTo('score', 10);
expect(matchesQuery(player, q)).toBe(true);
q = new Parse.Query('Person');
q.notEqualTo('score', 12);
expect(matchesQuery(player, q)).toBe(false);
q.notEqualTo('score', 40);
expect(matchesQuery(player, q)).toBe(true);
});
it('matches an $or query', function() {
var player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12
};
var q = new Parse.Query('Player');
q.equalTo('name', 'Player 1');
var q2 = new Parse.Query('Player');
q2.equalTo('name', 'Player 2');
var orQuery = Parse.Query.or(q, q2);
expect(matchesQuery(player, q)).toBe(true);
expect(matchesQuery(player, q2)).toBe(false);
expect(matchesQuery(player, orQuery)).toBe(true);
});
it('matches $regex queries', function() {
var player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12
};
var q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
q.startsWith('name', 'Ploy');
expect(matchesQuery(player, q)).toBe(false);
q = new Parse.Query('Player');
q.endsWith('name', ' 1');
expect(matchesQuery(player, q)).toBe(true);
q.endsWith('name', ' 2');
expect(matchesQuery(player, q)).toBe(false);
// Check that special characters are escaped
player.name = 'Android-7';
q = new Parse.Query('Player');
q.contains('name', 'd-7');
expect(matchesQuery(player, q)).toBe(true);
q = new Parse.Query('Player');
q.matches('name', /A.d/);
expect(matchesQuery(player, q)).toBe(true);
q.matches('name', /A[^n]d/);
expect(matchesQuery(player, q)).toBe(false);
// Check that the string \\E is returned to normal
player.name = 'Slash \\E';
q = new Parse.Query('Player');
q.endsWith('name', 'h \\E');
expect(matchesQuery(player, q)).toBe(true);
q.endsWith('name', 'h \\Ee');
expect(matchesQuery(player, q)).toBe(false);
player.name = 'Slash \\Q and more';
q = new Parse.Query('Player');
q.contains('name', 'h \\Q and');
expect(matchesQuery(player, q)).toBe(true);
q.contains('name', 'h \\Q or');
expect(matchesQuery(player, q)).toBe(false);
});
it('matches $nearSphere queries', function() {
var q = new Parse.Query('Checkin');
q.near('location', new Parse.GeoPoint(20, 20));
// With no max distance, any GeoPoint is 'near'
var pt = {
id: new Id('Checkin', 'C1'),
location: new Parse.GeoPoint(40, 40)
};
expect(matchesQuery(pt, q)).toBe(true);
q = new Parse.Query('Checkin');
pt.location = new Parse.GeoPoint(40, 40);
q.withinRadians('location', new Parse.GeoPoint(30, 30), 0.3);
expect(matchesQuery(pt, q)).toBe(true);
q.withinRadians('location', new Parse.GeoPoint(30, 30), 0.2);
expect(matchesQuery(pt, q)).toBe(false);
});
it('matches $within queries', function() {
var caltrainStation = {
id: new Id('Checkin', 'C1'),
location: new Parse.GeoPoint(37.776346, -122.394218),
name: 'Caltrain'
};
var santaClara = {
id: new Id('Checkin', 'C2'),
location: new Parse.GeoPoint(37.325635, -121.945753),
name: 'Santa Clara'
};
var q = new Parse.Query('Checkin').withinGeoBox(
'location',
new Parse.GeoPoint(37.708813, -122.526398),
new Parse.GeoPoint(37.822802, -122.373962)
);
expect(matchesQuery(caltrainStation, q)).toBe(true);
expect(matchesQuery(santaClara, q)).toBe(false);
// Invalid rectangles
q = new Parse.Query('Checkin').withinGeoBox(
'location',
new Parse.GeoPoint(37.822802, -122.373962),
new Parse.GeoPoint(37.708813, -122.526398)
);
expect(matchesQuery(caltrainStation, q)).toBe(false);
expect(matchesQuery(santaClara, q)).toBe(false);
q = new Parse.Query('Checkin').withinGeoBox(
'location',
new Parse.GeoPoint(37.708813, -122.373962),
new Parse.GeoPoint(37.822802, -122.526398)
);
expect(matchesQuery(caltrainStation, q)).toBe(false);
expect(matchesQuery(santaClara, q)).toBe(false);
});
});

29
spec/RedisPubSub.spec.js Normal file
View File

@@ -0,0 +1,29 @@
var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub;
describe('RedisPubSub', function() {
beforeEach(function(done) {
// Mock redis
var createClient = jasmine.createSpy('createClient');
jasmine.mockLibrary('redis', 'createClient', createClient);
done();
});
it('can create publisher', function() {
var publisher = RedisPubSub.createPublisher('redisAddress');
var redis = require('redis');
expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true });
});
it('can create subscriber', function() {
var subscriber = RedisPubSub.createSubscriber('redisAddress');
var redis = require('redis');
expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true });
});
afterEach(function() {
jasmine.restoreLibrary('redis', 'createClient');
});
});

View File

@@ -0,0 +1,52 @@
var SessionTokenCache = require('../src/LiveQuery/SessionTokenCache').SessionTokenCache;
describe('SessionTokenCache', function() {
beforeEach(function(done) {
var Parse = require('parse/node');
// Mock parse
var mockUser = {
become: jasmine.createSpy('become').and.returnValue(Parse.Promise.as({
id: 'userId'
}))
}
jasmine.mockLibrary('parse/node', 'User', mockUser);
done();
});
it('can get undefined userId', function(done) {
var sessionTokenCache = new SessionTokenCache();
sessionTokenCache.getUserId(undefined).then((userIdFromCache) => {
}, (error) => {
expect(error).not.toBeNull();
done();
});
});
it('can get existing userId', function(done) {
var sessionTokenCache = new SessionTokenCache();
var sessionToken = 'sessionToken';
var userId = 'userId'
sessionTokenCache.cache.set(sessionToken, userId);
sessionTokenCache.getUserId(sessionToken).then((userIdFromCache) => {
expect(userIdFromCache).toBe(userId);
done();
});
});
it('can get new userId', function(done) {
var sessionTokenCache = new SessionTokenCache();
sessionTokenCache.getUserId('sessionToken').then((userIdFromCache) => {
expect(userIdFromCache).toBe('userId');
expect(sessionTokenCache.cache.length).toBe(1);
done();
});
});
afterEach(function() {
jasmine.restoreLibrary('parse/node', 'User');
});
});

123
spec/Subscription.spec.js Normal file
View File

@@ -0,0 +1,123 @@
var Subscription = require('../src/LiveQuery/Subscription').Subscription;
describe('Subscription', function() {
beforeEach(function() {
var mockError = jasmine.createSpy('error');
jasmine.mockLibrary('../src/LiveQuery/PLog', 'error', mockError);
});
it('can be initialized', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
expect(subscription.className).toBe('className');
expect(subscription.query).toEqual({ key : 'value' });
expect(subscription.hash).toBe('hash');
expect(subscription.clientRequestIds.size).toBe(0);
});
it('can check it has subscribing clients', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
expect(subscription.hasSubscribingClient()).toBe(false);
});
it('can check it does not have subscribing clients', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
expect(subscription.hasSubscribingClient()).toBe(true);
});
it('can add one request for one client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
expect(subscription.clientRequestIds.size).toBe(1);
expect(subscription.clientRequestIds.get(1)).toEqual([1]);
});
it('can add requests for one client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.addClientSubscription(1, 2);
expect(subscription.clientRequestIds.size).toBe(1);
expect(subscription.clientRequestIds.get(1)).toEqual([1, 2]);
});
it('can add requests for clients', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.addClientSubscription(1, 2);
subscription.addClientSubscription(2, 2);
subscription.addClientSubscription(2, 3);
expect(subscription.clientRequestIds.size).toBe(2);
expect(subscription.clientRequestIds.get(1)).toEqual([1, 2]);
expect(subscription.clientRequestIds.get(2)).toEqual([2, 3]);
});
it('can delete requests for nonexistent client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.deleteClientSubscription(1, 1);
var PLog =require('../src/LiveQuery/PLog');
expect(PLog.error).toHaveBeenCalled();
});
it('can delete nonexistent request for one client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.deleteClientSubscription(1, 2);
var PLog =require('../src/LiveQuery/PLog');
expect(PLog.error).toHaveBeenCalled();
expect(subscription.clientRequestIds.size).toBe(1);
expect(subscription.clientRequestIds.get(1)).toEqual([1]);
});
it('can delete some requests for one client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.addClientSubscription(1, 2);
subscription.deleteClientSubscription(1, 2);
var PLog =require('../src/LiveQuery/PLog');
expect(PLog.error).not.toHaveBeenCalled();
expect(subscription.clientRequestIds.size).toBe(1);
expect(subscription.clientRequestIds.get(1)).toEqual([1]);
});
it('can delete all requests for one client', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.addClientSubscription(1, 2);
subscription.deleteClientSubscription(1, 1);
subscription.deleteClientSubscription(1, 2);
var PLog =require('../src/LiveQuery/PLog');
expect(PLog.error).not.toHaveBeenCalled();
expect(subscription.clientRequestIds.size).toBe(0);
});
it('can delete requests for multiple clients', function() {
var subscription = new Subscription('className', { key : 'value' }, 'hash');
subscription.addClientSubscription(1, 1);
subscription.addClientSubscription(1, 2);
subscription.addClientSubscription(2, 1);
subscription.addClientSubscription(2, 2);
subscription.deleteClientSubscription(1, 2);
subscription.deleteClientSubscription(2, 1);
subscription.deleteClientSubscription(2, 2);
var PLog =require('../src/LiveQuery/PLog');
expect(PLog.error).not.toHaveBeenCalled();
expect(subscription.clientRequestIds.size).toBe(1);
expect(subscription.clientRequestIds.get(1)).toEqual([1]);
});
afterEach(function(){
jasmine.restoreLibrary('../src/LiveQuery/PLog', 'error');
});
});

View File

@@ -252,3 +252,22 @@ global.jequal = jequal;
global.range = range;
global.setServerConfiguration = setServerConfiguration;
global.defaultConfiguration = defaultConfiguration;
// LiveQuery test setting
require('../src/LiveQuery/PLog').logLevel = 'NONE';
var libraryCache = {};
jasmine.mockLibrary = function(library, name, mock) {
var original = require(library)[name];
if (!libraryCache[library]) {
libraryCache[library] = {};
}
require(library)[name] = mock;
libraryCache[library][name] = original;
}
jasmine.restoreLibrary = function(library, name) {
if (!libraryCache[library] || !libraryCache[library][name]) {
throw 'Can not find library ' + library + ' ' + name;
}
require(library)[name] = libraryCache[library][name];
}

View File

@@ -22,7 +22,7 @@ export class Config {
this.facebookAppIds = cacheInfo.facebookAppIds;
this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix);
this.serverURL = cacheInfo.serverURL;
this.publicServerURL = cacheInfo.publicServerURL;
this.verifyUserEmails = cacheInfo.verifyUserEmails;
@@ -36,14 +36,15 @@ export class Config {
this.authDataManager = cacheInfo.authDataManager;
this.customPages = cacheInfo.customPages || {};
this.mount = mount;
this.liveQueryController = cacheInfo.liveQueryController;
}
static validate(options) {
this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails,
appName: options.appName,
this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails,
appName: options.appName,
publicServerURL: options.publicServerURL})
}
static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) {
if (verifyUserEmails) {
if (typeof appName !== 'string') {
@@ -58,23 +59,23 @@ export class Config {
get invalidLinkURL() {
return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`;
}
get verifyEmailSuccessURL() {
return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`;
}
get choosePasswordURL() {
return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`;
}
get requestResetPasswordURL() {
return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`;
}
get passwordResetSuccessURL() {
return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`;
}
get verifyEmailURL() {
return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`;
}

View File

@@ -0,0 +1,51 @@
import { ParseCloudCodePublisher } from '../LiveQuery/ParseCloudCodePublisher';
export class LiveQueryController {
classNames: any;
liveQueryPublisher: any;
constructor(config: any) {
let classNames;
// If config is empty, we just assume no classs needs to be registered as LiveQuery
if (!config || !config.classNames) {
this.classNames = new Set();
} else if (config.classNames instanceof Array) {
this.classNames = new Set(config.classNames);
} else {
throw 'liveQuery.classes should be an array of string'
}
this.liveQueryPublisher = new ParseCloudCodePublisher(config);
}
onAfterSave(className: string, currentObject: any, originalObject: any) {
if (!this.hasLiveQuery(className)) {
return;
}
let req = this._makePublisherRequest(currentObject, originalObject);
this.liveQueryPublisher.onCloudCodeAfterSave(req);
}
onAfterDelete(className: string, currentObject: any, originalObject: any) {
if (!this.hasLiveQuery(className)) {
return;
}
let req = this._makePublisherRequest(currentObject, originalObject);
this.liveQueryPublisher.onCloudCodeAfterDelete(req);
}
hasLiveQuery(className: string): boolean {
return this.classNames.has(className);
}
_makePublisherRequest(currentObject: any, originalObject: any): any {
let req = {
object: currentObject
};
if (currentObject) {
req.original = originalObject;
}
return req;
}
}
export default LiveQueryController;

104
src/LiveQuery/Client.js Normal file
View File

@@ -0,0 +1,104 @@
import PLog from './PLog';
import Parse from 'parse/node';
import type { FlattenedObjectData } from './Subscription';
export type Message = { [attr: string]: any };
let dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL'];
class Client {
id: number;
parseWebSocket: any;
userId: string;
roles: Array<string>;
subscriptionInfos: Object;
pushConnect: Function;
pushSubscribe: Function;
pushUnsubscribe: Function;
pushCreate: Function;
pushEnter: Function;
pushUpdate: Function;
pushDelete: Function;
pushLeave: Function;
constructor(id: number, parseWebSocket: any) {
this.id = id;
this.parseWebSocket = parseWebSocket;
this.roles = [];
this.subscriptionInfos = new Map();
this.pushConnect = this._pushEvent('connected');
this.pushSubscribe = this._pushEvent('subscribed');
this.pushUnsubscribe = this._pushEvent('unsubscribed');
this.pushCreate = this._pushEvent('create');
this.pushEnter = this._pushEvent('enter');
this.pushUpdate = this._pushEvent('update');
this.pushDelete = this._pushEvent('delete');
this.pushLeave = this._pushEvent('leave');
}
static pushResponse(parseWebSocket: any, message: Message): void {
PLog.verbose('Push Response : %j', message);
parseWebSocket.send(message);
}
static pushError(parseWebSocket: any, code: number, error: string, reconnect: boolean = true): void {
Client.pushResponse(parseWebSocket, JSON.stringify({
'op': 'error',
'error': error,
'code': code,
'reconnect': reconnect
}));
}
addSubscriptionInfo(requestId: number, subscriptionInfo: any): void {
this.subscriptionInfos.set(requestId, subscriptionInfo);
}
getSubscriptionInfo(requestId: numner): any {
return this.subscriptionInfos.get(requestId);
}
deleteSubscriptionInfo(requestId: number): void {
return this.subscriptionInfos.delete(requestId);
}
_pushEvent(type: string): Function {
return function(subscriptionId: number, parseObjectJSON: any): void {
let response: Message = {
'op' : type,
'clientId' : this.id
};
if (typeof subscriptionId !== 'undefined') {
response['requestId'] = subscriptionId;
}
if (typeof parseObjectJSON !== 'undefined') {
let fields;
if (this.subscriptionInfos.has(subscriptionId)) {
fields = this.subscriptionInfos.get(subscriptionId).fields;
}
response['object'] = this._toJSONWithFields(parseObjectJSON, fields);
}
Client.pushResponse(this.parseWebSocket, JSON.stringify(response));
}
}
_toJSONWithFields(parseObjectJSON: any, fields: any): FlattenedObjectData {
if (!fields) {
return parseObjectJSON;
}
let limitedParseObject = {};
for (let field of dafaultFields) {
limitedParseObject[field] = parseObjectJSON[field];
}
for (let field of fields) {
if (field in parseObjectJSON) {
limitedParseObject[field] = parseObjectJSON[field];
}
}
return limitedParseObject;
}
}
export {
Client
}

View File

@@ -0,0 +1,59 @@
import events from 'events';
let emitter = new events.EventEmitter();
class Publisher {
emitter: any;
constructor(emitter: any) {
this.emitter = emitter;
}
publish(channel: string, message: string): void {
this.emitter.emit(channel, message);
}
}
class Subscriber extends events.EventEmitter {
emitter: any;
subscriptions: any;
constructor(emitter: any) {
super();
this.emitter = emitter;
this.subscriptions = new Map();
}
subscribe(channel: string): void {
let handler = (message) => {
this.emit('message', channel, message);
}
this.subscriptions.set(channel, handler);
this.emitter.on(channel, handler);
}
unsubscribe(channel: string): void {
if (!this.subscriptions.has(channel)) {
return;
}
this.emitter.removeListener(channel, this.subscriptions.get(channel));
this.subscriptions.delete(channel);
}
}
function createPublisher(): any {
return new Publisher(emitter);
}
function createSubscriber(): any {
return new Subscriber(emitter);
}
let EventEmitterPubSub = {
createPublisher,
createSubscriber
}
export {
EventEmitterPubSub
}

22
src/LiveQuery/Id.js Normal file
View File

@@ -0,0 +1,22 @@
class Id {
className: string;
objectId: string;
constructor(className: string, objectId: string) {
this.className = className;
this.objectId = objectId;
}
toString(): string {
return this.className + ':' + this.objectId;
}
static fromString(str: string) {
var split = str.split(':');
if (split.length !== 2) {
throw new TypeError('Cannot create Id object from this string');
}
return new Id(split[0], split[1]);
}
}
module.exports = Id;

41
src/LiveQuery/PLog.js Normal file
View File

@@ -0,0 +1,41 @@
let LogLevel = {
'VERBOSE': 0,
'DEBUG': 1,
'INFO': 2,
'ERROR': 3,
'NONE': 4
}
function getCurrentLogLevel() {
if (PLog.logLevel && PLog.logLevel in LogLevel) {
return LogLevel[PLog.logLevel];
}
return LogLevel['ERROR'];
}
function verbose(): void {
if (getCurrentLogLevel() <= LogLevel['VERBOSE']) {
console.log.apply(console, arguments)
}
}
function log(): void {
if (getCurrentLogLevel() <= LogLevel['INFO']) {
console.log.apply(console, arguments)
}
}
function error(): void {
if (getCurrentLogLevel() <= LogLevel['ERROR']) {
console.error.apply(console, arguments)
}
}
let PLog = {
log: log,
error: error,
verbose: verbose,
logLevel: 'INFO'
};
module.exports = PLog;

View File

@@ -0,0 +1,37 @@
import { ParsePubSub } from './ParsePubSub';
import PLog from './PLog';
class ParseCloudCodePublisher {
parsePublisher: Object;
// config object of the publisher, right now it only contains the redisURL,
// but we may extend it later.
constructor(config: any = {}) {
this.parsePublisher = ParsePubSub.createPublisher(config);
}
onCloudCodeAfterSave(request: any): void {
this._onCloudCodeMessage('afterSave', request);
}
onCloudCodeAfterDelete(request: any): void {
this._onCloudCodeMessage('afterDelete', request);
}
// Request is the request object from cloud code functions. request.object is a ParseObject.
_onCloudCodeMessage(type: string, request: any): void {
PLog.verbose('Raw request from cloud code current : %j | original : %j', request.object, request.original);
// We need the full JSON which includes className
let message = {
currentParseObject: request.object._toFullJSON()
}
if (request.original) {
message.originalParseObject = request.original._toFullJSON();
}
this.parsePublisher.publish(type, JSON.stringify(message));
}
}
export {
ParseCloudCodePublisher
}

View File

@@ -0,0 +1,460 @@
import tv4 from 'tv4';
import Parse from 'parse/node';
import { Subscription } from './Subscription';
import { Client } from './Client';
import { ParseWebSocketServer } from './ParseWebSocketServer';
import PLog from './PLog';
import RequestSchema from './RequestSchema';
import { matchesQuery, queryHash } from './QueryTools';
import { ParsePubSub } from './ParsePubSub';
import { SessionTokenCache } from './SessionTokenCache';
class ParseLiveQueryServer {
clientId: number;
clients: Object;
// className -> (queryHash -> subscription)
subscriptions: Object;
parseWebSocketServer: Object;
keyPairs : any;
// The subscriber we use to get object update from publisher
subscriber: Object;
constructor(server: any, config: any) {
this.clientId = 0;
this.clients = new Map();
this.subscriptions = new Map();
config = config || {};
// Set LogLevel
PLog.logLevel = config.logLevel || 'INFO';
// Store keys, convert obj to map
let keyPairs = config.keyPairs || {};
this.keyPairs = new Map();
for (let key of Object.keys(keyPairs)) {
this.keyPairs.set(key, keyPairs[key]);
}
PLog.verbose('Support key pairs', this.keyPairs);
// Initialize Parse
Parse.Object.disableSingleInstance();
Parse.User.enableUnsafeCurrentUser();
let serverURL = config.serverURL || Parse.serverURL;
Parse.serverURL = serverURL;
let appId = config.appId || Parse.applicationId;
let javascriptKey = Parse.javaScriptKey;
let masterKey = config.masterKey || Parse.masterKey;
Parse.initialize(appId, javascriptKey, masterKey);
// Initialize websocket server
this.parseWebSocketServer = new ParseWebSocketServer(
server,
(parseWebsocket) => this._onConnect(parseWebsocket),
config.websocketTimeout
);
// Initialize subscriber
this.subscriber = ParsePubSub.createSubscriber({
redisURL: config.redisURL
});
this.subscriber.subscribe('afterSave');
this.subscriber.subscribe('afterDelete');
// Register message handler for subscriber. When publisher get messages, it will publish message
// to the subscribers and the handler will be called.
this.subscriber.on('message', (channel, messageStr) => {
PLog.verbose('Subscribe messsage %j', messageStr);
let message = JSON.parse(messageStr);
this._inflateParseObject(message);
if (channel === 'afterSave') {
this._onAfterSave(message);
} else if (channel === 'afterDelete') {
this._onAfterDelete(message);
} else {
PLog.error('Get message %s from unknown channel %j', message, channel);
}
});
// Initialize sessionToken cache
this.sessionTokenCache = new SessionTokenCache(config.cacheTimeout);
}
// Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes.
// Message.originalParseObject is the original ParseObject JSON.
_inflateParseObject(message: any): void {
// Inflate merged object
let currentParseObject = message.currentParseObject;
let className = currentParseObject.className;
let parseObject = new Parse.Object(className);
parseObject._finishFetch(currentParseObject);
message.currentParseObject = parseObject;
// Inflate original object
let originalParseObject = message.originalParseObject;
if (originalParseObject) {
className = originalParseObject.className;
parseObject = new Parse.Object(className);
parseObject._finishFetch(originalParseObject);
message.originalParseObject = parseObject;
}
}
// Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes.
// Message.originalParseObject is the original ParseObject.
_onAfterDelete(message: any): void {
PLog.verbose('afterDelete is triggered');
let deletedParseObject = message.currentParseObject.toJSON();
let className = deletedParseObject.className;
PLog.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id);
PLog.verbose('Current client number : %d', this.clients.size);
let classSubscriptions = this.subscriptions.get(className);
if (typeof classSubscriptions === 'undefined') {
PLog.error('Can not find subscriptions under this class ' + className);
return;
}
for (let subscription of classSubscriptions.values()) {
let isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
if (!isSubscriptionMatched) {
continue;
}
for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) {
let client = this.clients.get(clientId);
if (typeof client === 'undefined') {
continue;
}
for (let requestId of requestIds) {
let acl = message.currentParseObject.getACL();
// Check ACL
this._matchesACL(acl, client, requestId).then((isMatched) => {
if (!isMatched) {
return null;
}
client.pushDelete(requestId, deletedParseObject);
}, (error) => {
PLog.error('Matching ACL error : ', error);
});
}
}
}
}
// Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes.
// Message.originalParseObject is the original ParseObject.
_onAfterSave(message: any): void {
PLog.verbose('afterSave is triggered');
let originalParseObject = null;
if (message.originalParseObject) {
originalParseObject = message.originalParseObject.toJSON();
}
let currentParseObject = message.currentParseObject.toJSON();
let className = currentParseObject.className;
PLog.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id);
PLog.verbose('Current client number : %d', this.clients.size);
let classSubscriptions = this.subscriptions.get(className);
if (typeof classSubscriptions === 'undefined') {
PLog.error('Can not find subscriptions under this class ' + className);
return;
}
for (let subscription of classSubscriptions.values()) {
let isOriginalSubscriptionMatched = this._matchesSubscription(originalParseObject, subscription);
let isCurrentSubscriptionMatched = this._matchesSubscription(currentParseObject, subscription);
for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) {
let client = this.clients.get(clientId);
if (typeof client === 'undefined') {
continue;
}
for (let requestId of requestIds) {
// Set orignal ParseObject ACL checking promise, if the object does not match
// subscription, we do not need to check ACL
let originalACLCheckingPromise;
if (!isOriginalSubscriptionMatched) {
originalACLCheckingPromise = Parse.Promise.as(false);
} else {
let originalACL;
if (message.originalParseObject) {
originalACL = message.originalParseObject.getACL();
}
originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId);
}
// Set current ParseObject ACL checking promise, if the object does not match
// subscription, we do not need to check ACL
let currentACLCheckingPromise;
if (!isCurrentSubscriptionMatched) {
currentACLCheckingPromise = Parse.Promise.as(false);
} else {
let currentACL = message.currentParseObject.getACL();
currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId);
}
Parse.Promise.when(
originalACLCheckingPromise,
currentACLCheckingPromise
).then((isOriginalMatched, isCurrentMatched) => {
PLog.verbose('Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
originalParseObject,
currentParseObject,
isOriginalSubscriptionMatched,
isCurrentSubscriptionMatched,
isOriginalMatched,
isCurrentMatched,
subscription.hash
);
// Decide event type
let type;
if (isOriginalMatched && isCurrentMatched) {
type = 'Update';
} else if (isOriginalMatched && !isCurrentMatched) {
type = 'Leave';
} else if (!isOriginalMatched && isCurrentMatched) {
if (originalParseObject) {
type = 'Enter';
} else {
type = 'Create';
}
} else {
return null;
}
let functionName = 'push' + type;
client[functionName](requestId, currentParseObject);
}, (error) => {
PLog.error('Matching ACL error : ', error);
});
}
}
}
}
_onConnect(parseWebsocket: any): void {
parseWebsocket.on('message', (request) => {
if (typeof request === 'string') {
request = JSON.parse(request);
}
PLog.verbose('Request: %j', request);
// Check whether this request is a valid request, return error directly if not
if (!tv4.validate(request, RequestSchema['general']) || !tv4.validate(request, RequestSchema[request.op])) {
Client.pushError(parseWebsocket, 1, tv4.error.message);
PLog.error('Connect message error %s', tv4.error.message);
return;
}
switch(request.op) {
case 'connect':
this._handleConnect(parseWebsocket, request);
break;
case 'subscribe':
this._handleSubscribe(parseWebsocket, request);
break;
case 'unsubscribe':
this._handleUnsubscribe(parseWebsocket, request);
break;
default:
Client.pushError(parseWebsocket, 3, 'Get unknown operation');
PLog.error('Get unknown operation', request.op);
}
});
parseWebsocket.on('disconnect', () => {
PLog.log('Client disconnect: %d', parseWebsocket.clientId);
let clientId = parseWebsocket.clientId;
if (!this.clients.has(clientId)) {
PLog.error('Can not find client %d on disconnect', clientId);
return;
}
// Delete client
let client = this.clients.get(clientId);
this.clients.delete(clientId);
// Delete client from subscriptions
for (let [requestId, subscriptionInfo] of client.subscriptionInfos.entries()) {
let subscription = subscriptionInfo.subscription;
subscription.deleteClientSubscription(clientId, requestId);
// If there is no client which is subscribing this subscription, remove it from subscriptions
let classSubscriptions = this.subscriptions.get(subscription.className);
if (!subscription.hasSubscribingClient()) {
classSubscriptions.delete(subscription.hash);
}
// If there is no subscriptions under this class, remove it from subscriptions
if (classSubscriptions.size === 0) {
this.subscriptions.delete(subscription.className);
}
}
PLog.verbose('Current clients %d', this.clients.size);
PLog.verbose('Current subscriptions %d', this.subscriptions.size);
});
}
_matchesSubscription(parseObject: any, subscription: any): boolean {
// Object is undefined or null, not match
if (!parseObject) {
return false;
}
return matchesQuery(parseObject, subscription.query);
}
_matchesACL(acl: any, client: any, requestId: number): any {
// If ACL is undefined or null, or ACL has public read access, return true directly
if (!acl || acl.getPublicReadAccess()) {
return Parse.Promise.as(true);
}
// Check subscription sessionToken matches ACL first
let subscriptionInfo = client.getSubscriptionInfo(requestId);
if (typeof subscriptionInfo === 'undefined') {
return Parse.Promise.as(false);
}
let subscriptionSessionToken = subscriptionInfo.sessionToken;
return this.sessionTokenCache.getUserId(subscriptionSessionToken).then((userId) => {
return acl.getReadAccess(userId);
}).then((isSubscriptionSessionTokenMatched) => {
if (isSubscriptionSessionTokenMatched) {
return Parse.Promise.as(true);
}
// Check client sessionToken matches ACL
let clientSessionToken = client.sessionToken;
return this.sessionTokenCache.getUserId(clientSessionToken).then((userId) => {
return acl.getReadAccess(userId);
});
}).then((isMatched) => {
return Parse.Promise.as(isMatched);
}, (error) => {
return Parse.Promise.as(false);
});
}
_handleConnect(parseWebsocket: any, request: any): any {
if (!this._validateKeys(request, this.keyPairs)) {
Client.pushError(parseWebsocket, 4, 'Key in request is not valid');
PLog.error('Key in request is not valid');
return;
}
let client = new Client(this.clientId, parseWebsocket);
parseWebsocket.clientId = this.clientId;
this.clientId += 1;
this.clients.set(parseWebsocket.clientId, client);
PLog.log('Create new client: %d', parseWebsocket.clientId);
client.pushConnect();
}
_validateKeys(request: any, validKeyPairs: any): boolean {
if (!validKeyPairs || validKeyPairs.size == 0) {
return true;
}
let isValid = false;
for (let [key, secret] of validKeyPairs) {
if (!request[key] || request[key] !== secret) {
continue;
}
isValid = true;
break;
}
return isValid;
}
_handleSubscribe(parseWebsocket: any, request: any): any {
// If we can not find this client, return error to client
if (!parseWebsocket.hasOwnProperty('clientId')) {
Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before subscribing');
PLog.error('Can not find this client, make sure you connect to server before subscribing');
return;
}
let client = this.clients.get(parseWebsocket.clientId);
// Get subscription from subscriptions, create one if necessary
let subscriptionHash = queryHash(request.query);
// Add className to subscriptions if necessary
let className = request.query.className;
if (!this.subscriptions.has(className)) {
this.subscriptions.set(className, new Map());
}
let classSubscriptions = this.subscriptions.get(className);
let subscription;
if (classSubscriptions.has(subscriptionHash)) {
subscription = classSubscriptions.get(subscriptionHash);
} else {
subscription = new Subscription(className, request.query.where, subscriptionHash);
classSubscriptions.set(subscriptionHash, subscription);
}
// Add subscriptionInfo to client
let subscriptionInfo = {
subscription: subscription
};
// Add selected fields and sessionToken for this subscription if necessary
if (request.query.fields) {
subscriptionInfo.fields = request.query.fields;
}
if (request.sessionToken) {
subscriptionInfo.sessionToken = request.sessionToken;
}
client.addSubscriptionInfo(request.requestId, subscriptionInfo);
// Add clientId to subscription
subscription.addClientSubscription(parseWebsocket.clientId, request.requestId);
client.pushSubscribe(request.requestId);
PLog.verbose('Create client %d new subscription: %d', parseWebsocket.clientId, request.requestId);
PLog.verbose('Current client number: %d', this.clients.size);
}
_handleUnsubscribe(parseWebsocket: any, request: any): any {
// If we can not find this client, return error to client
if (!parseWebsocket.hasOwnProperty('clientId')) {
Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before unsubscribing');
PLog.error('Can not find this client, make sure you connect to server before unsubscribing');
return;
}
let requestId = request.requestId;
let client = this.clients.get(parseWebsocket.clientId);
if (typeof client === 'undefined') {
Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId +
'. Make sure you connect to live query server before unsubscribing.');
PLog.error('Can not find this client ' + parseWebsocket.clientId);
return;
}
let subscriptionInfo = client.getSubscriptionInfo(requestId);
if (typeof subscriptionInfo === 'undefined') {
Client.pushError(parseWebsocket, 2, 'Cannot find subscription with clientId ' + parseWebsocket.clientId +
' subscriptionId ' + requestId + '. Make sure you subscribe to live query server before unsubscribing.');
PLog.error('Can not find subscription with clientId ' + parseWebsocket.clientId + ' subscriptionId ' + requestId);
return;
}
// Remove subscription from client
client.deleteSubscriptionInfo(requestId);
// Remove client from subscription
let subscription = subscriptionInfo.subscription;
let className = subscription.className;
subscription.deleteClientSubscription(parseWebsocket.clientId, requestId);
// If there is no client which is subscribing this subscription, remove it from subscriptions
let classSubscriptions = this.subscriptions.get(className);
if (!subscription.hasSubscribingClient()) {
classSubscriptions.delete(subscription.hash);
}
// If there is no subscriptions under this class, remove it from subscriptions
if (classSubscriptions.size === 0) {
this.subscriptions.delete(className);
}
client.pushUnsubscribe(request.requestId);
PLog.verbose('Delete client: %d | subscription: %d', parseWebsocket.clientId, request.requestId);
}
}
ParseLiveQueryServer.setLogLevel = function(logLevel) {
PLog.logLevel = logLevel;
}
export {
ParseLiveQueryServer
}

View File

@@ -0,0 +1,29 @@
import { RedisPubSub } from './RedisPubSub';
import { EventEmitterPubSub } from './EventEmitterPubSub';
let ParsePubSub = {};
function useRedis(config: any): boolean {
let redisURL = config.redisURL;
return typeof redisURL !== 'undefined' && redisURL !== '';
}
ParsePubSub.createPublisher = function(config: any): any {
if (useRedis(config)) {
return RedisPubSub.createPublisher(config.redisURL);
} else {
return EventEmitterPubSub.createPublisher();
}
}
ParsePubSub.createSubscriber = function(config: any): void {
if (useRedis(config)) {
return RedisPubSub.createSubscriber(config.redisURL);
} else {
return EventEmitterPubSub.createSubscriber();
}
}
export {
ParsePubSub
}

View File

@@ -0,0 +1,44 @@
import PLog from './PLog';
let typeMap = new Map([['disconnect', 'close']]);
export class ParseWebSocketServer {
server: Object;
constructor(server: any, onConnect: Function, websocketTimeout: number = 10 * 1000) {
let WebSocketServer = require('ws').Server;
let wss = new WebSocketServer({ server: server });
wss.on('listening', () => {
PLog.log('Parse LiveQuery Server starts running');
});
wss.on('connection', (ws) => {
onConnect(new ParseWebSocket(ws));
// Send ping to client periodically
let pingIntervalId = setInterval(() => {
if (ws.readyState == ws.OPEN) {
ws.ping();
} else {
clearInterval(pingIntervalId);
}
}, websocketTimeout);
});
this.server = wss;
}
}
export class ParseWebSocket {
ws: any;
constructor(ws: any) {
this.ws = ws;
}
on(type: string, callback): void {
let wsType = typeMap.has(type) ? typeMap.get(type) : type;
this.ws.on(wsType, callback);
}
send(message: any, channel: string): void {
this.ws.send(message);
}
}

280
src/LiveQuery/QueryTools.js Normal file
View File

@@ -0,0 +1,280 @@
var equalObjects = require('./equalObjects');
var Id = require('./Id');
var Parse = require('parse/node');
/**
* Query Hashes are deterministic hashes for Parse Queries.
* Any two queries that have the same set of constraints will produce the same
* hash. This lets us reliably group components by the queries they depend upon,
* and quickly determine if a query has changed.
*/
/**
* Convert $or queries into an array of where conditions
*/
function flattenOrQueries(where) {
if (!where.hasOwnProperty('$or')) {
return where;
}
var accum = [];
for (var i = 0; i < where.$or.length; i++) {
accum = accum.concat(where.$or[i]);
}
return accum;
}
/**
* Deterministically turns an object into a string. Disregards ordering
*/
function stringify(object): string {
if (typeof object !== 'object' || object === null) {
if (typeof object === 'string') {
return '"' + object.replace(/\|/g, '%|') + '"';
}
return object + '';
}
if (Array.isArray(object)) {
var copy = object.map(stringify);
copy.sort();
return '[' + copy.join(',') + ']';
}
var sections = [];
var keys = Object.keys(object);
keys.sort();
for (var k = 0; k < keys.length; k++) {
sections.push(stringify(keys[k]) + ':' + stringify(object[keys[k]]));
}
return '{' + sections.join(',') + '}';
}
/**
* Generate a hash from a query, with unique fields for columns, values, order,
* skip, and limit.
*/
function queryHash(query) {
if (query instanceof Parse.Query) {
query = {
className: query.className,
where: query._where
}
}
var where = flattenOrQueries(query.where || {});
var columns = [];
var values = [];
var i;
if (Array.isArray(where)) {
var uniqueColumns = {};
for (i = 0; i < where.length; i++) {
var subValues = {};
var keys = Object.keys(where[i]);
keys.sort();
for (var j = 0; j < keys.length; j++) {
subValues[keys[j]] = where[i][keys[j]];
uniqueColumns[keys[j]] = true;
}
values.push(subValues);
}
columns = Object.keys(uniqueColumns);
columns.sort();
} else {
columns = Object.keys(where);
columns.sort();
for (i = 0; i < columns.length; i++) {
values.push(where[columns[i]]);
}
}
var sections = [columns.join(','), stringify(values)];
return query.className + ':' + sections.join('|');
}
/**
* matchesQuery -- Determines if an object would be returned by a Parse Query
* It's a lightweight, where-clause only implementation of a full query engine.
* Since we find queries that match objects, rather than objects that match
* queries, we can avoid building a full-blown query tool.
*/
function matchesQuery(object: any, query: any): boolean {
if (query instanceof Parse.Query) {
var className =
(object.id instanceof Id) ? object.id.className : object.className;
if (className !== query.className) {
return false;
}
return matchesQuery(object, query._where);
}
for (var field in query) {
if (!matchesKeyConstraints(object, field, query[field])) {
return false;
}
}
return true;
}
/**
* Determines whether an object matches a single key's constraints
*/
function matchesKeyConstraints(object, key, constraints) {
var i;
if (key === '$or') {
for (i = 0; i < constraints.length; i++) {
if (matchesQuery(object, constraints[i])) {
return true;
}
}
return false;
}
if (key === '$relatedTo') {
// Bail! We can't handle relational queries locally
return false;
}
// Equality (or Array contains) cases
if (typeof constraints !== 'object') {
if (Array.isArray(object[key])) {
return object[key].indexOf(constraints) > -1;
}
return object[key] === constraints;
}
var compareTo;
if (constraints.__type) {
if (constraints.__type === 'Pointer') {
return (
constraints.className === object[key].className &&
constraints.objectId === object[key].objectId
);
}
compareTo = Parse._decode(key, constraints);
if (Array.isArray(object[key])) {
for (i = 0; i < object[key].length; i++) {
if (equalObjects(object[key][i], compareTo)) {
return true;
}
}
return false;
}
return equalObjects(object[key], compareTo);
}
// More complex cases
for (var condition in constraints) {
compareTo = constraints[condition];
if (compareTo.__type) {
compareTo = Parse._decode(key, compareTo);
}
switch (condition) {
case '$lt':
if (object[key] >= compareTo) {
return false;
}
break;
case '$lte':
if (object[key] > compareTo) {
return false;
}
break;
case '$gt':
if (object[key] <= compareTo) {
return false;
}
break;
case '$gte':
if (object[key] < compareTo) {
return false;
}
break;
case '$ne':
if (equalObjects(object[key], compareTo)) {
return false;
}
break;
case '$in':
if (compareTo.indexOf(object[key]) < 0) {
return false;
}
break;
case '$nin':
if (compareTo.indexOf(object[key]) > -1) {
return false;
}
break;
case '$all':
for (i = 0; i < compareTo.length; i++) {
if (object[key].indexOf(compareTo[i]) < 0) {
return false;
}
}
break;
case '$exists':
if (typeof object[key] === 'undefined') {
return false;
}
break;
case '$regex':
if (typeof compareTo === 'object') {
return compareTo.test(object[key]);
}
// JS doesn't support perl-style escaping
var expString = '';
var escapeEnd = -2;
var escapeStart = compareTo.indexOf('\\Q');
while (escapeStart > -1) {
// Add the unescaped portion
expString += compareTo.substring(escapeEnd + 2, escapeStart);
escapeEnd = compareTo.indexOf('\\E', escapeStart);
if (escapeEnd > -1) {
expString += compareTo.substring(escapeStart + 2, escapeEnd)
.replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&');
}
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
}
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
var exp = new RegExp(expString, constraints.$options || '');
if (!exp.test(object[key])) {
return false;
}
break;
case '$nearSphere':
var distance = compareTo.radiansTo(object[key]);
var max = constraints.$maxDistance || Infinity;
return distance <= max;
case '$within':
var southWest = compareTo.$box[0];
var northEast = compareTo.$box[1];
if (southWest.latitude > northEast.latitude ||
southWest.longitude > northEast.longitude) {
// Invalid box, crosses the date line
return false;
}
return (
object[key].latitude > southWest.latitude &&
object[key].latitude < northEast.latitude &&
object[key].longitude > southWest.longitude &&
object[key].longitude < northEast.longitude
);
case '$options':
// Not a query type, but a way to add options to $regex. Ignore and
// avoid the default
break;
case '$maxDistance':
// Not a query type, but a way to add a cap to $nearSphere. Ignore and
// avoid the default
break;
case '$select':
return false;
case '$dontSelect':
return false;
default:
return false;
}
}
return true;
}
var QueryTools = {
queryHash: queryHash,
matchesQuery: matchesQuery
};
module.exports = QueryTools;

View File

@@ -0,0 +1,18 @@
import redis from 'redis';
function createPublisher(redisURL: string): any {
return redis.createClient(redisURL, { no_ready_check: true });
}
function createSubscriber(redisURL: string): any {
return redis.createClient(redisURL, { no_ready_check: true });
}
let RedisPubSub = {
createPublisher,
createSubscriber
}
export {
RedisPubSub
}

View File

@@ -0,0 +1,101 @@
let general = {
'title': 'General request schema',
'type': 'object',
'properties': {
'op': {
'type': 'string',
'enum': ['connect', 'subscribe', 'unsubscribe']
},
},
};
let connect = {
'title': 'Connect operation schema',
'type': 'object',
'properties': {
'op': 'connect',
'applicationId': {
'type': 'string'
},
'javascriptKey': {
type: 'string'
},
'masterKey': {
type: 'string'
},
'clientKey': {
type: 'string'
},
'windowsKey': {
type: 'string'
},
'restAPIKey': {
'type': 'string'
},
'sessionToken': {
'type': 'string'
}
},
'required': ['op', 'applicationId'],
"additionalProperties": false
};
let subscribe = {
'title': 'Subscribe operation schema',
'type': 'object',
'properties': {
'op': 'subscribe',
'requestId': {
'type': 'number'
},
'query': {
'title': 'Query field schema',
'type': 'object',
'properties': {
'className': {
'type': 'string'
},
'where': {
'type': 'object'
},
'fields': {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"uniqueItems": true
}
},
'required': ['where', 'className'],
'additionalProperties': false
},
'sessionToken': {
'type': 'string'
}
},
'required': ['op', 'requestId', 'query'],
'additionalProperties': false
};
let unsubscribe = {
'title': 'Unsubscribe operation schema',
'type': 'object',
'properties': {
'op': 'unsubscribe',
'requestId': {
'type': 'number'
}
},
'required': ['op', 'requestId'],
"additionalProperties": false
}
let RequestSchema = {
'general': general,
'connect': connect,
'subscribe': subscribe,
'unsubscribe': unsubscribe
}
export default RequestSchema;

View File

@@ -0,0 +1,38 @@
import Parse from 'parse/node';
import LRU from 'lru-cache';
import PLog from './PLog';
class SessionTokenCache {
cache: Object;
constructor(timeout: number = 30 * 24 * 60 *60 * 1000, maxSize: number = 10000) {
this.cache = new LRU({
max: maxSize,
maxAge: timeout
});
}
getUserId(sessionToken: string): any {
if (!sessionToken) {
return Parse.Promise.error('Empty sessionToken');
}
let userId = this.cache.get(sessionToken);
if (userId) {
PLog.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken);
return Parse.Promise.as(userId);
}
return Parse.User.become(sessionToken).then((user) => {
PLog.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken);
let userId = user.id;
this.cache.set(sessionToken, userId);
return Parse.Promise.as(userId);
}, (error) => {
PLog.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error);
return Parse.Promise.error(error);
});
}
}
export {
SessionTokenCache
}

View File

@@ -0,0 +1,55 @@
import {matchesQuery, queryHash} from './QueryTools';
import PLog from './PLog';
export type FlattenedObjectData = { [attr: string]: any };
export type QueryData = { [attr: string]: any };
class Subscription {
// It is query condition eg query.where
query: QueryData;
className: string;
hash: string;
clientRequestIds: Object;
constructor(className: string, query: QueryData, queryHash: string) {
this.className = className;
this.query = query;
this.hash = queryHash;
this.clientRequestIds = new Map();
}
addClientSubscription(clientId: number, requestId: number): void {
if (!this.clientRequestIds.has(clientId)) {
this.clientRequestIds.set(clientId, []);
}
let requestIds = this.clientRequestIds.get(clientId);
requestIds.push(requestId);
}
deleteClientSubscription(clientId: number, requestId: number): void {
let requestIds = this.clientRequestIds.get(clientId);
if (typeof requestIds === 'undefined') {
PLog.error('Can not find client %d to delete', clientId);
return;
}
let index = requestIds.indexOf(requestId);
if (index < 0) {
PLog.error('Can not find client %d subscription %d to delete', clientId, requestId);
return;
}
requestIds.splice(index, 1);
// Delete client reference if it has no subscription
if (requestIds.length == 0) {
this.clientRequestIds.delete(clientId);
}
}
hasSubscribingClient(): boolean {
return this.clientRequestIds.size > 0;
}
}
export {
Subscription
}

View File

@@ -0,0 +1,48 @@
var toString = Object.prototype.toString;
/**
* Determines whether two objects represent the same primitive, special Parse
* type, or full Parse Object.
*/
function equalObjects(a, b) {
if (typeof a !== typeof b) {
return false;
}
if (typeof a !== 'object') {
return (a === b);
}
if (a === b) {
return true;
}
if (toString.call(a) === '[object Date]') {
if (toString.call(b) === '[object Date]') {
return (+a === +b);
}
return false;
}
if (Array.isArray(a)) {
if (Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
if (!equalObjects(a[i], b[i])) {
return false;
}
}
return true;
}
return false;
}
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
}
for (var key in a) {
if (!equalObjects(a[key], b[key])) {
return false;
}
}
return true;
}
module.exports = equalObjects;

View File

@@ -266,6 +266,7 @@ RestWrite.prototype.findUsersWithAuthData = function(authData) {
return findPromise;
}
RestWrite.prototype.handleAuthData = function(authData) {
let results;
return this.handleAuthDataValidation(authData).then(() => {
@@ -768,7 +769,9 @@ RestWrite.prototype.runAfterTrigger = function() {
}
// Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class.
if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) {
let hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId);
let hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className);
if (!hasAfterSaveHook && !hasLiveQuery) {
return Promise.resolve();
}
@@ -789,6 +792,10 @@ RestWrite.prototype.runAfterTrigger = function() {
updatedObject.set(this.sanitizedData());
updatedObject._handleSaveResponse(this.response.response, this.response.status || 200);
// Notifiy LiveQueryServer if possible
this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject);
// Run afterSave trigger
triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId);
};

View File

@@ -11,41 +11,43 @@ var batch = require('./batch'),
Parse = require('parse/node').Parse,
authDataManager = require('./authDataManager');
//import passwordReset from './passwordReset';
import cache from './cache';
import Config from './Config';
import parseServerPackage from '../package.json';
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
import PromiseRouter from './PromiseRouter';
import requiredParameter from './requiredParameter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { ClassesRouter } from './Routers/ClassesRouter';
import { FeaturesRouter } from './Routers/FeaturesRouter';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { FilesController } from './Controllers/FilesController';
import { FilesRouter } from './Routers/FilesRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
import { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { HooksController } from './Controllers/HooksController';
import { HooksRouter } from './Routers/HooksRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { LoggerController } from './Controllers/LoggerController';
import { LogsRouter } from './Routers/LogsRouter';
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
import { PushController } from './Controllers/PushController';
import { PushRouter } from './Routers/PushRouter';
import { randomString } from './cryptoUtils';
import { RolesRouter } from './Routers/RolesRouter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { setFeature } from './features';
import { UserController } from './Controllers/UserController';
import { UsersRouter } from './Routers/UsersRouter';
//import passwordReset from './passwordReset';
import cache from './cache';
import Config from './Config';
import parseServerPackage from '../package.json';
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
import PromiseRouter from './PromiseRouter';
import requiredParameter from './requiredParameter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { ClassesRouter } from './Routers/ClassesRouter';
import { FeaturesRouter } from './Routers/FeaturesRouter';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { FilesController } from './Controllers/FilesController';
import { FilesRouter } from './Routers/FilesRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
import { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { HooksController } from './Controllers/HooksController';
import { HooksRouter } from './Routers/HooksRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { LiveQueryController } from './Controllers/LiveQueryController';
import { LoggerController } from './Controllers/LoggerController';
import { LogsRouter } from './Routers/LogsRouter';
import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer';
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
import { PushController } from './Controllers/PushController';
import { PushRouter } from './Routers/PushRouter';
import { randomString } from './cryptoUtils';
import { RolesRouter } from './Routers/RolesRouter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { setFeature } from './features';
import { UserController } from './Controllers/UserController';
import { UsersRouter } from './Routers/UsersRouter';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
@@ -108,6 +110,7 @@ function ParseServer({
choosePassword: undefined,
passwordResetSuccess: undefined
},
liveQuery = {}
}) {
setFeature('serverVersion', parseServerPackage.version);
// Initialize the node client SDK automatically
@@ -151,6 +154,7 @@ function ParseServer({
const loggerController = new LoggerController(loggerControllerAdapter, appId);
const hooksController = new HooksController(appId, collectionPrefix);
const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails });
const liveQueryController = new LiveQueryController(liveQuery);
cache.apps.set(appId, {
@@ -174,6 +178,7 @@ function ParseServer({
appName: appName,
publicServerURL: publicServerURL,
customPages: customPages,
liveQueryController: liveQueryController
});
// To maintain compatibility. TODO: Remove in some version that breaks backwards compatability
@@ -262,6 +267,10 @@ function addParseCloud() {
global.Parse = Parse;
}
ParseServer.createLiveQueryServer = function(httpServer, config) {
return new ParseLiveQueryServer(httpServer, config);
}
module.exports = {
ParseServer: ParseServer,
S3Adapter: S3Adapter,

View File

@@ -42,6 +42,7 @@ function del(config, auth, className, objectId) {
return Promise.resolve().then(() => {
if (triggers.getTrigger(className, triggers.Types.beforeDelete, config.applicationId) ||
triggers.getTrigger(className, triggers.Types.afterDelete, config.applicationId) ||
(config.liveQueryController && config.liveQueryController.hasLiveQuery(className)) ||
className == '_Session') {
return find(config, Auth.master(config), className, {objectId: objectId})
.then((response) => {
@@ -49,6 +50,8 @@ function del(config, auth, className, objectId) {
response.results[0].className = className;
cache.users.remove(response.results[0].sessionToken);
inflatedObject = Parse.Object.fromJSON(response.results[0]);
// Notify LiveQuery server if possible
config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject);
return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId);
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
@@ -97,7 +100,8 @@ function update(config, auth, className, objectId, restObject) {
return Promise.resolve().then(() => {
if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) ||
triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId)) {
triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId) ||
(config.liveQueryController && config.liveQueryController.hasLiveQuery(className))) {
return find(config, Auth.master(config), className, {objectId: objectId});
}
return Promise.resolve({});

View File

@@ -3,6 +3,7 @@ import cache from './cache';
import * as middlewares from './middlewares';
import { ParseServer } from './index';
import { Parse } from 'parse/node';
var express = require('express'),
cryptoUtils = require('./cryptoUtils');