From af3ac4c6ecbaa10572c0dfb278b3f2386f297079 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 09:42:35 -0800 Subject: [PATCH 01/23] Add class creation logic with validation --- ExportAdapter.js | 8 +- Schema.js | 199 +++++++++++++++++++++++++++++++++++++++++++- schemas.js | 4 +- spec/Schema.spec.js | 98 ++++++++++++++++++++++ 4 files changed, 298 insertions(+), 11 deletions(-) diff --git a/ExportAdapter.js b/ExportAdapter.js index 830b41807d..4a626fa2b9 100644 --- a/ExportAdapter.js +++ b/ExportAdapter.js @@ -60,13 +60,7 @@ ExportAdapter.prototype.connect = function() { var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/; ExportAdapter.prototype.collection = function(className) { - if (className !== '_User' && - className !== '_Installation' && - className !== '_Session' && - className !== '_SCHEMA' && - className !== '_Role' && - !joinRegex.test(className) && - !otherRegex.test(className)) { + if (!Schema.classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className); } diff --git a/Schema.js b/Schema.js index 66d1d45205..5514dca263 100644 --- a/Schema.js +++ b/Schema.js @@ -17,6 +17,137 @@ var Parse = require('parse/node').Parse; var transform = require('./transform'); +defaultColumns = { + // Contain the default columns for every parse object type (except _Join collection) + _Default: { + "objectId": {type:'String'}, + "createdAt": {type:'Date'}, + "updatedAt": {type:'Date'}, + "ACL": {type:'ACL'}, + }, + // The additional default columns for the _User collection (in addition to DefaultCols) + _User: { + "username": {type:'String'}, + "password": {type:'String'}, + "authData": {type:'Object'}, + "email": {type:'String'}, + "emailVerified": {type:'Boolean'}, + }, + // The additional default columns for the _User collection (in addition to DefaultCols) + _Installation: { + "installationId": {type:'String'}, + "deviceToken": {type:'String'}, + "channels": {type:'Array'}, + "deviceType": {type:'String'}, + "pushType": {type:'String'}, + "GCMSenderId": {type:'String'}, + "timeZone": {type:'String'}, + "localeIdentifier": {type:'String'}, + "badge": {type:'Number'}, + }, + // The additional default columns for the _User collection (in addition to DefaultCols) + _Role: { + "name": {type:'String'}, + "users": {type:'Relation',className:'_User'}, + "roles": {type:'Relation',className:'_Role'}, + }, + // The additional default columns for the _User collection (in addition to DefaultCols) + _Session: { + "restricted": {type:'Boolean'}, + "user": {type:'Pointer', className:'_User'}, + "installationId": {type:'String'}, + "sessionToken": {type:'String'}, + "expiresAt": {type:'Date'}, + "createdWith": {type:'Object'}, + }, +} + +// Valid classes must: +// Be one of _User, _Installation, _Role, _Session OR +// Be a join table OR +// Include only alpha-numeric and underscores, and not start with an underscore or number +var joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; +var classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/; +function classNameIsValid(className) { + return ( + className === '_User' || + className === '_Installation' || + className === '_Session' || + className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. + className === '_Role' || + joinClassRegex.test(className) || + classAndFieldRegex.test(className) + ); +} + +// Valid fields must be alpha-numeric, and not start with an underscore or number +function fieldNameIsValid(fieldName) { + return classAndFieldRegex.test(fieldName); +} + +// Checks that it's not trying to clobber one of the default fields of the class. +function fieldNameIsValidForClass(fieldName, className) { + if (!fieldNameIsValid(fieldName)) { + return false; + } + if (defaultColumns._Default[fieldName]) { + return false; + } + if (defaultColumns[className] && defaultColumns[className][fieldName]) { + return false; + } + return true; +} + +function invalidClassNameMessage(className) { + if (!className) { + className = ''; + } + return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character '; +} + +// Returns { error: "message", code: ### } if the type could not be +// converted, otherwise returns a returns { result: "mongotype" } +// where mongotype is suitable for inserting into mongo _SCHEMA collection +function schemaAPITypeToMongoFieldType(type) { + var invalidJsonError = { error: "invalid JSON", code: Parse.Error.INVALID_JSON }; + if (type.type == 'Pointer') { + if (!type.targetClass) { + return { error: 'type Pointer needs a class name', code: 135 }; + } else if (typeof type.targetClass !== 'string') { + return invalidJsonError; + } else if (!classNameIsValid(type.targetClass)) { + return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME }; + } else { + return { result: '*' + type.targetClass }; + } + } + if (type.type == 'Relation') { + if (!type.targetClass) { + return { error: 'type Relation needs a class name', code: 135 }; + } else if (typeof type.targetClass !== 'string') { + return invalidJsonError; + } else if (!classNameIsValid(type.targetClass)) { + return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME }; + } else { + return { result: 'relation<' + type.targetClass + '>' }; + } + } + if (typeof type.type !== 'string') { + return { error: "invalid JSON", code: Parse.Error.INVALID_JSON }; + } + switch (type.type) { + default : return { error: 'invalid field type: ' + type.type }; + case 'Number': return { result: 'number' }; + case 'String': return { result: 'string' }; + case 'Boolean': return { result: 'boolean' }; + case 'Date': return { result: 'date' }; + case 'Object': return { result: 'object' }; + case 'Array': return { result: 'array' }; + case 'GeoPoint': return { result: 'geopoint' }; + case 'File': return { result: 'file' }; + } +} // Create a schema from a Mongo collection and the exported schema format. // mongoSchema should be a list of objects, each with: @@ -71,9 +202,72 @@ Schema.prototype.reload = function() { return load(this.collection); }; +// Create a new class that includes the three default fields. +// ACL is an implicit column that does not get an entry in the +// _SCHEMAS database. Returns a promise that resolves with the +// created schema, in mongo format. +// on success, and rejects with an error on fail. Ensure you +// have authorization (master key, or client class creation +// enabled) before calling this function. +Schema.prototype.addClassIfNotExists = function(className, fields) { + if (this.data[className]) { + return Promise.reject(new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'class ' + className + ' already exists' + )); + } + + if (!classNameIsValid(className)) { + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: invalidClassNameMessage(className), + }); + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + }); + } + for (fieldName in fields) { + if (!fieldNameIsValid(fieldName)) { + return Promise.reject({ + code: Parse.Error.INVALID_KEY_NAME, + error: 'invalid field name: ' + fieldName, + }); + } + if (!fieldNameIsValidForClass(fieldName, className)) { + return Promise.reject({ + code: 136, + error: 'field ' + fieldName + ' cannot be added', + }); + } + } + + + + return this.collection.insertOne({ + _id: className, + objectId: 'string', + updatedAt: 'string', + createdAt: 'string', + }) + .then(result => result.ops[0]) + .catch(error => { + if (error.code === 11000) { //Mongo's duplicate key error + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + className + ' already exists', + }); + } + return Promise.reject(error); + }); +} + // Returns a promise that resolves successfully to the new schema -// object. +// object or fails with a reason. // If 'freeze' is true, refuse to update the schema. +// WARNING: this function has side-effects, and doesn't actually +// do any validation of the format of the className. You probably +// should use classNameIsValid or addClassIfNotExists or something +// like that instead. TODO: rename or remove this function. Schema.prototype.validateClassName = function(className, freeze) { if (this.data[className]) { return Promise.resolve(this); @@ -348,5 +542,6 @@ function getObjectType(obj) { module.exports = { - load: load + load: load, + classNameIsValid: classNameIsValid, }; diff --git a/schemas.js b/schemas.js index 88b0da38fe..3aac67e7e2 100644 --- a/schemas.js +++ b/schemas.js @@ -5,7 +5,7 @@ var express = require('express'), var router = new PromiseRouter(); -function mongoFieldTypeToApiResponseType(type) { +function mongoFieldTypeToSchemaAPIType(type) { if (type[0] === '*') { return { type: 'Pointer', @@ -34,7 +34,7 @@ function mongoSchemaAPIResponseFields(schema) { fieldNames = Object.keys(schema).filter(key => key !== '_id'); response = {}; fieldNames.forEach(fieldName => { - response[fieldName] = mongoFieldTypeToApiResponseType(schema[fieldName]); + response[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]); }); response.ACL = {type: 'ACL'}; response.createdAt = {type: 'Date'}; diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index abf178ab03..c142f628b8 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -131,4 +131,102 @@ describe('Schema', () => { }); }); }); + + it('can add classes without needing an object', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'String'} + })) + .then(result => { + expect(result).toEqual({ + _id: 'NewClass', + objectId: 'string', + updatedAt: 'string', + createdAt: 'string' + }) + done(); + }); + }); + + it('will fail to create a class if that class was already created by an object', done => { + config.database.loadSchema() + .then(schema => { + schema.validateObject('NewClass', {foo: 7}) + .then(() => { + schema.addClassIfNotExists('NewClass', { + foo: {type: 'String'} + }).catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME) + expect(error.error).toEqual('class NewClass already exists'); + done(); + }); + }); + }) + }); + + it('will resolve class creation races appropriately', done => { + // If two callers race to create the same schema, the response to the + // loser should be the same as if they hadn't been racing. Furthermore, + // The caller that wins the race should resolve it's promise before the + // caller that loses the race. + config.database.loadSchema() + .then(schema => { + var p1 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); + var p2 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); + var raceWinnerHasSucceeded = false; + var raceLoserHasFailed = false; + Promise.race([p1, p2]) //Use race because we expect the first completed promise to be the successful one + .then(response => { + raceWinnerHasSucceeded = true; + expect(raceLoserHasFailed).toEqual(false); + expect(response).toEqual({ + _id: 'NewClass', + objectId: 'string', + updatedAt: 'string', + createdAt: 'string' + }); + }); + Promise.all([p1,p2]) + .catch(error => { + expect(raceWinnerHasSucceeded).toEqual(true); + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.error).toEqual('class NewClass already exists'); + done(); + raceLoserHasFailed = true; + }); + }); + }); + + it('refuses to create classes with invalid names', done => { + config.database.loadSchema() + .then(schema => { + schema.addClassIfNotExists('_InvalidName', {foo: {type: 'String'}}) + .catch(error => { + expect(error.error).toEqual( + 'Invalid classname: _InvalidName, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); + done(); + }); + }); + }); + + it('refuses to add fields with invalid names', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', {'0InvalidName': {type: 'String'}})) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); + expect(error.error).toEqual('invalid field name: 0InvalidName'); + done(); + }); + }); + + it('refuses to explicitly create the default fields', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Installation', {localeIdentifier: {type: 'String'}})) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.error).toEqual('field localeIdentifier cannot be added'); + done(); + }); + }); }); From 134f4cbf34c3624adb5a6ce714294e966ddf9496 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 11:21:17 -0800 Subject: [PATCH 02/23] Add and test logic for adding fields to the DB --- ExportAdapter.js | 1 + Schema.js | 34 +++++++++-- spec/Schema.spec.js | 139 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 7 deletions(-) diff --git a/ExportAdapter.js b/ExportAdapter.js index 4a626fa2b9..df417ac83a 100644 --- a/ExportAdapter.js +++ b/ExportAdapter.js @@ -494,6 +494,7 @@ ExportAdapter.prototype.smartFind = function(coll, where, options) { var index = {}; index[key] = '2d'; + //TODO: condiser moving index creation logic into Schema.js return coll.createIndex(index).then(() => { // Retry, but just once. return coll.find(where, options).toArray(); diff --git a/Schema.js b/Schema.js index 5514dca263..b0ea4d816d 100644 --- a/Schema.js +++ b/Schema.js @@ -137,7 +137,7 @@ function schemaAPITypeToMongoFieldType(type) { return { error: "invalid JSON", code: Parse.Error.INVALID_JSON }; } switch (type.type) { - default : return { error: 'invalid field type: ' + type.type }; + default: return { error: 'invalid field type: ' + type.type }; case 'Number': return { result: 'number' }; case 'String': return { result: 'string' }; case 'Boolean': return { result: 'boolean' }; @@ -241,14 +241,38 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { } } - - - return this.collection.insertOne({ + var mongoObject = { _id: className, objectId: 'string', updatedAt: 'string', createdAt: 'string', - }) + }; + for (fieldName in defaultColumns[className]) { + validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); + if (validatedField.code) { + return Promise.reject(validatedField); + } + mongoObject[fieldName] = validatedField.result; + } + + for (fieldName in fields) { + validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); + if (validatedField.code) { + return Promise.reject(validatedField); + } + mongoObject[fieldName] = validatedField.result; + } + + var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint'); + + if (geoPoints.length > 1) { + return Promise.reject({ + code: Parse.Error.INCORRECT_TYPE, + error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', + }); + } + + return this.collection.insertOne(mongoObject) .then(result => result.ops[0]) .catch(error => { if (error.code === 11000) { //Mongo's duplicate key error diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index c142f628b8..ce6f596875 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,6 +1,7 @@ // These tests check that the Schema operates correctly. var Config = require('../Config'); var Schema = require('../Schema'); +var dd = require('deep-diff'); var config = new Config('test'); @@ -142,7 +143,8 @@ describe('Schema', () => { _id: 'NewClass', objectId: 'string', updatedAt: 'string', - createdAt: 'string' + createdAt: 'string', + foo: 'string', }) done(); }); @@ -183,7 +185,8 @@ describe('Schema', () => { _id: 'NewClass', objectId: 'string', updatedAt: 'string', - createdAt: 'string' + createdAt: 'string', + foo: 'string', }); }); Promise.all([p1,p2]) @@ -229,4 +232,136 @@ describe('Schema', () => { done(); }); }); + + it('refuses to add fields with invalid types', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 7} + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.error).toEqual('invalid JSON'); + done(); + }); + }); + + it('refuses to add fields with invalid pointer types', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Pointer'}, + })) + .catch(error => { + expect(error.code).toEqual(135); + expect(error.error).toEqual('type Pointer needs a class name'); + done(); + }); + }); + + it('refuses to add fields with invalid pointer target', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Pointer', targetClass: 7}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.error).toEqual('invalid JSON'); + done(); + }); + }); + + it('refuses to add fields with invalid Relation type', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Relation', uselessKey: 7}, + })) + .catch(error => { + expect(error.code).toEqual(135); + expect(error.error).toEqual('type Relation needs a class name'); + done(); + }); + }); + + it('refuses to add fields with invalid relation target', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Relation', targetClass: 7}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.error).toEqual('invalid JSON'); + done(); + }); + }); + + it('refuses to add fields with uncreatable pointer target class', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Pointer', targetClass: 'not a valid class name'}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + done(); + }); + }); + + it('refuses to add fields with uncreatable relation target class', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Relation', targetClass: 'not a valid class name'}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + done(); + }); + }); + + it('will create classes', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + aNumber: {type: 'Number'}, + aString: {type: 'String'}, + aBool: {type: 'Boolean'}, + aDate: {type: 'Date'}, + aObject: {type: 'Object'}, + aArray: {type: 'Array'}, + aGeoPoint: {type: 'GeoPoint'}, + aFile: {type: 'File'}, + aPointer: {type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet'}, + aRelation: {type: 'Relation', targetClass: 'NewClass'}, + })) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: 'NewClass', + objectId: 'string', + createdAt: 'string', + updatedAt: 'string', + aNumber: 'number', + aString: 'string', + aBool: 'boolean', + aDate: 'date', + aObject: 'object', + aArray: 'array', + aGeoPoint: 'geopoint', + aFile: 'file', + aPointer: '*ThisClassDoesNotExistYet', + aRelation: 'relation', + }); + done(); + }); + }); + + it('refuses to create two geopoints', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + geo1: {type: 'GeoPoint'}, + geo2: {type: 'GeoPoint'}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(error.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding geo2 when geo1 already exists.'); + done(); + }); + }); }); From 669923dbfec29b3be3385a734fa8ecc4c642ff77 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 12:48:36 -0800 Subject: [PATCH 03/23] More tests --- Schema.js | 16 ++++--------- spec/Schema.spec.js | 56 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/Schema.js b/Schema.js index b0ea4d816d..3f590ac7d2 100644 --- a/Schema.js +++ b/Schema.js @@ -100,9 +100,6 @@ function fieldNameIsValidForClass(fieldName, className) { } function invalidClassNameMessage(className) { - if (!className) { - className = ''; - } return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character '; } @@ -137,7 +134,7 @@ function schemaAPITypeToMongoFieldType(type) { return { error: "invalid JSON", code: Parse.Error.INVALID_JSON }; } switch (type.type) { - default: return { error: 'invalid field type: ' + type.type }; + default: return { error: 'invalid field type: ' + type.type, code: Parse.Error.INCORRECT_TYPE }; case 'Number': return { result: 'number' }; case 'String': return { result: 'string' }; case 'Boolean': return { result: 'boolean' }; @@ -211,10 +208,10 @@ Schema.prototype.reload = function() { // enabled) before calling this function. Schema.prototype.addClassIfNotExists = function(className, fields) { if (this.data[className]) { - return Promise.reject(new Parse.Error( - Parse.Error.DUPLICATE_VALUE, - 'class ' + className + ' already exists' - )); + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + className + ' already exists', + }); } if (!classNameIsValid(className)) { @@ -222,9 +219,6 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { code: Parse.Error.INVALID_CLASS_NAME, error: invalidClassNameMessage(className), }); - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - }); } for (fieldName in fields) { if (!fieldNameIsValid(fieldName)) { diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index ce6f596875..a9cd62719e 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -155,9 +155,11 @@ describe('Schema', () => { .then(schema => { schema.validateObject('NewClass', {foo: 7}) .then(() => { - schema.addClassIfNotExists('NewClass', { + schema.reload() + .then(schema => schema.addClassIfNotExists('NewClass', { foo: {type: 'String'} - }).catch(error => { + })) + .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME) expect(error.error).toEqual('class NewClass already exists'); done(); @@ -223,7 +225,17 @@ describe('Schema', () => { }); }); - it('refuses to explicitly create the default fields', done => { + it('refuses to explicitly create the default fields for custom classes', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', {objectId: {type: 'String'}})) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.error).toEqual('field objectId cannot be added'); + done(); + }); + }); + + it('refuses to explicitly create the default fields for non-custom classes', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('_Installation', {localeIdentifier: {type: 'String'}})) .catch(error => { @@ -317,6 +329,18 @@ describe('Schema', () => { }); }); + it('refuses to add fields with unknown types', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { + foo: {type: 'Unknown'}, + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(error.error).toEqual('invalid field type: Unknown'); + done(); + }); + }); + it('will create classes', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { @@ -352,6 +376,32 @@ describe('Schema', () => { }); }); + it('creates the default fields for non-custom classes', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Installation', { + foo: {type: 'Number'}, + })) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: '_Installation', + createdAt: 'string', + updatedAt: 'string', + objectId: 'string', + foo: 'number', + installationId: 'string', + deviceToken: 'string', + channels: 'array', + deviceType: 'string', + pushType: 'string', + GCMSenderId: 'string', + timeZone: 'string', + localeIdentifier: 'string', + badge: 'number', + }); + done(); + }); + }); + it('refuses to create two geopoints', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { From 79c2c9ffc565a59d754c9ac9fed2f6675128d771 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 12:56:45 -0800 Subject: [PATCH 04/23] Updates tests to allow calls that race to create a schema to have the race loser return before the race winner. This test failed in mongo 2.6.11, and I don't know if thats because it's generally flaky or if that version of mongo makes less guarantees. --- spec/Schema.spec.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index a9cd62719e..ccc83525b6 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -170,19 +170,13 @@ describe('Schema', () => { it('will resolve class creation races appropriately', done => { // If two callers race to create the same schema, the response to the - // loser should be the same as if they hadn't been racing. Furthermore, - // The caller that wins the race should resolve it's promise before the - // caller that loses the race. + // race loser should be the same as if they hadn't been racing. config.database.loadSchema() .then(schema => { var p1 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); var p2 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); - var raceWinnerHasSucceeded = false; - var raceLoserHasFailed = false; Promise.race([p1, p2]) //Use race because we expect the first completed promise to be the successful one .then(response => { - raceWinnerHasSucceeded = true; - expect(raceLoserHasFailed).toEqual(false); expect(response).toEqual({ _id: 'NewClass', objectId: 'string', @@ -193,11 +187,9 @@ describe('Schema', () => { }); Promise.all([p1,p2]) .catch(error => { - expect(raceWinnerHasSucceeded).toEqual(true); expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); expect(error.error).toEqual('class NewClass already exists'); done(); - raceLoserHasFailed = true; }); }); }); From 2c59c2879a288020537a047c203f473835937989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Sun, 31 Jan 2016 20:08:07 +0000 Subject: [PATCH 05/23] Added ability to add a validation function to a Cloud Code function --- functions.js | 8 ++++++++ index.js | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/functions.js b/functions.js index 97074715a1..269f7cd259 100644 --- a/functions.js +++ b/functions.js @@ -9,6 +9,14 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { + // Run the validator for this function first + if (Parse.Cloud.Validators[req.params.functionName]) { + var result = Parse.Cloud.Validators[req.params.functionName](req.body); + if (!result) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Validation failed.'); + } + } + return new Promise(function (resolve, reject) { var response = createResponseObject(resolve, reject); var request = { diff --git a/index.js b/index.js index 9f8a5a4702..258f1ed9e2 100644 --- a/index.js +++ b/index.js @@ -127,14 +127,17 @@ function ParseServer(args) { function addParseCloud() { Parse.Cloud.Functions = {}; + Parse.Cloud.Validators = {}; Parse.Cloud.Triggers = { beforeSave: {}, beforeDelete: {}, afterSave: {}, afterDelete: {} }; - Parse.Cloud.define = function(functionName, handler) { + + Parse.Cloud.define = function(functionName, handler, validationHandler) { Parse.Cloud.Functions[functionName] = handler; + Parse.Cloud.Validators[functionName] = validationHandler; }; Parse.Cloud.beforeSave = function(parseClass, handler) { var className = getClassName(parseClass); From ffadae391a263f6ee65df5605e37930407e8fcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Wed, 3 Feb 2016 21:51:40 +0000 Subject: [PATCH 06/23] Added function validation --- functions.js | 1 - index.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/functions.js b/functions.js index 269f7cd259..cd8162bdf3 100644 --- a/functions.js +++ b/functions.js @@ -9,7 +9,6 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { - // Run the validator for this function first if (Parse.Cloud.Validators[req.params.functionName]) { var result = Parse.Cloud.Validators[req.params.functionName](req.body); if (!result) { diff --git a/index.js b/index.js index 258f1ed9e2..5f818eb20d 100644 --- a/index.js +++ b/index.js @@ -134,7 +134,7 @@ function addParseCloud() { afterSave: {}, afterDelete: {} }; - + Parse.Cloud.define = function(functionName, handler, validationHandler) { Parse.Cloud.Functions[functionName] = handler; Parse.Cloud.Validators[functionName] = validationHandler; From f4216bd36d186fcd628bf425342e1661e25545c0 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 3 Feb 2016 16:10:00 -0800 Subject: [PATCH 07/23] Implement GET /schemas/OneSchema --- schemas.js | 20 +++++ spec/schemas.spec.js | 201 +++++++++++++++++++++++++++---------------- 2 files changed, 149 insertions(+), 72 deletions(-) diff --git a/schemas.js b/schemas.js index 3aac67e7e2..4a8d50b4ee 100644 --- a/schemas.js +++ b/schemas.js @@ -64,6 +64,26 @@ function getAllSchemas(req) { }})); } +function getOneSchema(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'unauthorized'}, + }); + } + return req.config.database.collection('_SCHEMA') + .then(coll => coll.findOne({'_id': req.params.className})) + .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) + .catch(() => ({ + status: 400, + response: { + code: 103, + error: 'class ' + req.params.className + ' does not exist', + } + })); +} + router.route('GET', '/schemas', getAllSchemas); +router.route('GET', '/schemas/:className', getOneSchema); module.exports = router; diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index a4d2f6188c..8c7434da49 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,5 +1,60 @@ var request = require('request'); var dd = require('deep-diff'); +var hasAllPODobject = () => { + var obj = new Parse.Object('HasAllPOD'); + obj.set('aNumber', 5); + obj.set('aString', 'string'); + obj.set('aBool', true); + obj.set('aDate', new Date()); + obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aArray', ['contents', true, 5]); + obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); + var objACL = new Parse.ACL(); + objACL.setPublicWriteAccess(false); + obj.setACL(objACL); + return obj; +} + +var expectedResponseForHasAllPOD = { + className: 'HasAllPOD', + fields: { + //Default fields + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + //Custom fields + aNumber: {type: 'Number'}, + aString: {type: 'String'}, + aBool: {type: 'Boolean'}, + aDate: {type: 'Date'}, + aObject: {type: 'Object'}, + aArray: {type: 'Array'}, + aGeoPoint: {type: 'GeoPoint'}, + aFile: {type: 'File'} + }, +}; + +var expectedResponseforHasPointersAndRelations = { + className: 'HasPointersAndRelations', + fields: { + //Default fields + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + //Custom fields + aPointer: { + type: 'Pointer', + targetClass: 'HasAllPOD', + }, + aRelation: { + type: 'Relation', + targetClass: 'HasAllPOD', + }, + }, +} describe('schemas', () => { it('requires the master key to get all schemas', (done) => { @@ -17,6 +72,21 @@ describe('schemas', () => { }); }); + it('requires the master key to get one schema', (done) => { + request.get({ + url: 'http://localhost:8378/1/schemas/SomeSchema', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('unauthorized'); + done(); + }); + }); + it('responds with empty list when there are no schemas', done => { request.get({ url: 'http://localhost:8378/1/schemas', @@ -32,79 +102,66 @@ describe('schemas', () => { }); it('responds with a list of schemas after creating objects', done => { - var obj1 = new Parse.Object('HasAllPOD'); - obj1.set('aNumber', 5); - obj1.set('aString', 'string'); - obj1.set('aBool', true); - obj1.set('aDate', new Date()); - obj1.set('aObject', {k1: 'value', k2: true, k3: 5}); - obj1.set('aArray', ['contents', true, 5]); - obj1.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); - obj1.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); - var obj1ACL = new Parse.ACL(); - obj1ACL.setPublicWriteAccess(false); - obj1.setACL(obj1ACL); + var obj1 = hasAllPODobject(); + obj1.save().then(savedObj1 => { + var obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + var relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); + }).then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + var expected = { + results: [expectedResponseForHasAllPOD,expectedResponseforHasPointersAndRelations] + }; + expect(body).toEqual(expected); + done(); + }) + }); + }); - obj1.save().then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); - obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); - relation.add(obj1); - return obj2.save(); - }).then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }, (error, response, body) => { - var expected = { - results: [ - { - className: 'HasAllPOD', - fields: { - //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - //Custom fields - aNumber: {type: 'Number'}, - aString: {type: 'String'}, - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'} - }, - }, - { - className: 'HasPointersAndRelations', - fields: { - //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - //Custom fields - aPointer: { - type: 'Pointer', - targetClass: 'HasAllPOD', - }, - aRelation: { - type: 'Relation', - targetClass: 'HasAllPOD', - }, - }, - } - ] - }; - expect(body).toEqual(expected); - done(); - }) + it('responds with a single schema', done => { + var obj = hasAllPODobject(); + obj.save().then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(body).toEqual(expectedResponseForHasAllPOD); + done(); }); + }); + }); + + it('treats class names case sensitively', done => { + var obj = hasAllPODobject(); + obj.save().then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/HASALLPOD', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: 103, + error: 'class HASALLPOD does not exist', + }); + done(); + }); + }); }); }); From 3fe2a309e98b51c614428d696a76d6b250c466d8 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Thu, 4 Feb 2016 00:08:31 -0800 Subject: [PATCH 08/23] Add APNS client --- APNS.js | 95 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- spec/APNS.spec.js | 58 +++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 APNS.js create mode 100644 spec/APNS.spec.js diff --git a/APNS.js b/APNS.js new file mode 100644 index 0000000000..5fc73ab0f2 --- /dev/null +++ b/APNS.js @@ -0,0 +1,95 @@ +var Parse = require('parse/node').Parse; +// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, +// but probably we will replace it in the future. +var apn = require('apn'); + +/** + * Create a new connection to the APN service. + * @constructor + * @param {Object} args Arguments to config APNS connection + * @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem + * @param {String} args.key The filename of the connection key to load from disk, default is key.pem + * @param {String} args.passphrase The passphrase for the connection key, if required + * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox + */ +function APNS(args) { + this.sender = new apn.connection(args); + + this.sender.on('connected', function() { + console.log('APNS Connected'); + }); + + this.sender.on('transmissionError', function(errCode, notification, device) { + console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification); + // TODO: For error caseud by invalid deviceToken, we should mark those installations. + }); + + this.sender.on("timeout", function () { + console.log("APNS Connection Timeout"); + }); + + this.sender.on("disconnected", function() { + console.log("APNS Disconnected"); + }); + + this.sender.on("socketError", console.error); +} + +/** + * Send apns request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} deviceTokens A array of device tokens + * @returns {Object} A promise which is resolved immediately + */ +APNS.prototype.send = function(data, deviceTokens) { + var coreData = data.data; + var expirationTime = data['expiration_time']; + var notification = generateNotification(coreData, expirationTime); + this.sender.pushNotification(notification, deviceTokens); + // TODO: pushNotification will push the notification to apn's queue. + // We do not handle error in V1, we just relies apn to auto retry and send the + // notifications. + return Parse.Promise.as(); +} + +/** + * Generate the apns notification from the data we get from api request. + * @param {Object} coreData The data field under api request body + * @returns {Object} A apns notification + */ +var generateNotification = function(coreData, expirationTime) { + var notification = new apn.notification(); + var payload = {}; + for (key in coreData) { + switch (key) { + case 'alert': + notification.setAlertText(coreData.alert); + break; + case 'badge': + notification.badge = coreData.badge; + break; + case 'sound': + notification.sound = coreData.sound; + break; + case 'content-available': + notification.setNewsstandAvailable(true); + var isAvailable = coreData['content-available'] === 1; + notification.setContentAvailable(isAvailable); + break; + case 'category': + notification.category = coreData.category; + break; + default: + payload[key] = coreData[key]; + break; + } + } + notification.payload = payload; + notification.expiry = expirationTime; + return notification; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + APNS.generateNotification = generateNotification; +} +module.exports = APNS; diff --git a/package.json b/package.json index b6039e572e..29afd4e0ea 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "apn": "^1.7.5", "aws-sdk": "~2.2.33", "bcrypt-nodejs": "0.0.3", "body-parser": "^1.14.2", @@ -30,7 +31,7 @@ }, "scripts": { "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", - "test": "TESTING=1 ./node_modules/.bin/istanbul cover --include-all-sources -x **/spec/** ./node_modules/.bin/jasmine", + "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/istanbul cover --include-all-sources -x **/spec/** ./node_modules/.bin/jasmine", "posttest": "mongodb-runner stop", "start": "./bin/parse-server" }, diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js new file mode 100644 index 0000000000..c50bb5c952 --- /dev/null +++ b/spec/APNS.spec.js @@ -0,0 +1,58 @@ +var APNS = require('../APNS'); + +describe('APNS', () => { + it('can generate APNS notification', (done) => { + //Mock request data + var data = { + 'alert': 'alert', + 'badge': 100, + 'sound': 'test', + 'content-available': 1, + 'category': 'INVITE_CATEGORY', + 'key': 'value', + 'keyAgain': 'valueAgain' + }; + var expirationTime = 1454571491354 + + var notification = APNS.generateNotification(data, expirationTime); + + expect(notification.alert).toEqual(data.alert); + expect(notification.badge).toEqual(data.badge); + expect(notification.sound).toEqual(data.sound); + expect(notification.contentAvailable).toEqual(1); + expect(notification.category).toEqual(data.category); + expect(notification.payload).toEqual({ + 'key': 'value', + 'keyAgain': 'valueAgain' + }); + expect(notification.expiry).toEqual(expirationTime); + done(); + }); + + it('can send APNS notification', (done) => { + var apns = new APNS(); + var sender = { + pushNotification: jasmine.createSpy('send') + }; + apns.sender = sender; + // Mock data + var expirationTime = 1454571491354 + var data = { + 'expiration_time': expirationTime, + 'data': { + 'alert': 'alert' + } + } + // Mock registrationTokens + var deviceTokens = ['token']; + + var promise = apns.send(data, deviceTokens); + expect(sender.pushNotification).toHaveBeenCalled(); + var args = sender.pushNotification.calls.first().args; + var notification = args[0]; + expect(notification.alert).toEqual(data.data.alert); + expect(notification.expiry).toEqual(data['expiration_time']); + expect(args[1]).toEqual(deviceTokens); + done(); + }); +}); From 69613bd66d1a993dcec01dc111bca0559ad84ecb Mon Sep 17 00:00:00 2001 From: German Laullon Date: Thu, 4 Feb 2016 23:42:01 +0100 Subject: [PATCH 09/23] Correct function response encoding This will encode all ParseObject on the correct way so they can be translated into PFObject by the IOS SDK. --- functions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions.js b/functions.js index cd8162bdf3..de6a2f2718 100644 --- a/functions.js +++ b/functions.js @@ -35,7 +35,7 @@ function createResponseObject(resolve, reject) { success: function(result) { resolve({ response: { - result: result + result: Parse._encode(result) } }); }, From 8706149f2222fb839b8ed68bb4c58ce653da6f5e Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Thu, 4 Feb 2016 19:14:37 -0800 Subject: [PATCH 10/23] Updated to 2.0.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 29afd4e0ea..92342c606c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.0.6", + "version": "2.0.7", "description": "An express module providing a Parse-compatible API server", "main": "index.js", "repository": { From b582f1ac2a988ba8632e8ef8c55b9b5ed4e45fa5 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Tue, 2 Feb 2016 19:51:40 -0800 Subject: [PATCH 11/23] Add push parameter checking and query installation --- index.js | 2 +- package.json | 1 + push.js | 121 +++++++++++++++++++++++++-- spec/push.spec.js | 206 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 spec/push.spec.js diff --git a/index.js b/index.js index 5f818eb20d..37a88b893a 100644 --- a/index.js +++ b/index.js @@ -111,7 +111,7 @@ function ParseServer(args) { router.merge(require('./sessions')); router.merge(require('./roles')); router.merge(require('./analytics')); - router.merge(require('./push')); + router.merge(require('./push').router); router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); diff --git a/package.json b/package.json index 92342c606c..8486245d87 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "mongodb": "~2.1.0", "multer": "^1.1.0", "parse": "^1.7.0", + "moment": "^2.11.1", "request": "^2.65.0" }, "devDependencies": { diff --git a/push.js b/push.js index 08a192c474..e7f0b9ca45 100644 --- a/push.js +++ b/push.js @@ -2,17 +2,124 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); + rest = require('./rest'), + moment = require('moment'); -var router = new PromiseRouter(); +var validPushTypes = ['ios', 'android']; + +function handlePushWithoutQueue(req) { + validateMasterKey(req); + var where = getQueryCondition(req); + validateDeviceType(where); + // Replace the expiration_time with a valid Unix epoch milliseconds time + req.body['expiration_time'] = getExpirationTime(req); + return rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, + 'This path is not implemented yet.'); + }); +} + +/** + * Check whether the deviceType parameter in qury condition is valid or not. + * @param {Object} where A query condition + */ +function validateDeviceType(where) { + var where = where || {}; + var deviceTypeField = where.deviceType || {}; + var deviceTypes = []; + if (typeof deviceTypeField === 'string') { + deviceTypes.push(deviceTypeField); + } else if (typeof deviceTypeField['$in'] === 'array') { + deviceTypes.concat(deviceTypeField['$in']); + } + for (var i = 0; i < deviceTypes.length; i++) { + var deviceType = deviceTypes[i]; + if (validPushTypes.indexOf(deviceType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + deviceType + ' is not supported push type.'); + } + } +} + +/** + * Get expiration time from the request body. + * @param {Object} request A request object + * @returns {Number|undefined} The expiration time if it exists in the request + */ +function getExpirationTime(req) { + var body = req.body || {}; + var hasExpirationTime = !!body['expiration_time']; + if (!hasExpirationTime) { + return; + } + var expirationTimeParam = body['expiration_time']; + var expirationTime; + if (typeof expirationTimeParam === 'number') { + expirationTime = new Date(expirationTimeParam * 1000); + } else if (typeof expirationTimeParam === 'string') { + expirationTime = new Date(expirationTimeParam); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.'); + } + // Check expirationTime is valid or not, if it is not valid, expirationTime is NaN + if (!isFinite(expirationTime)) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.'); + } + return expirationTime.valueOf(); +} +/** + * Get query condition from the request body. + * @param {Object} request A request object + * @returns {Object} The query condition, the where field in a query api call + */ +function getQueryCondition(req) { + var body = req.body || {}; + var hasWhere = typeof body.where !== 'undefined'; + var hasChannels = typeof body.channels !== 'undefined'; + var where; + if (hasWhere && hasChannels) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Channels and query can not be set at the same time.'); + } else if (hasWhere) { + where = body.where; + } else if (hasChannels) { + where = { + "channels": { + "$in": body.channels + } + } + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Channels and query should be set at least one.'); + } + return where; +} -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); +/** + * Check whether the api call has master key or not. + * @param {Object} request A request object + */ +function validateMasterKey(req) { + if (req.info.masterKey !== req.config.masterKey) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Master key is invalid, you should only use master key to send push'); + } } -router.route('POST','/push', notImplementedYet); +var router = new PromiseRouter(); +router.route('POST','/push', handlePushWithoutQueue); + +module.exports = { + router: router +} -module.exports = router; \ No newline at end of file +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + module.exports.getQueryCondition = getQueryCondition; + module.exports.validateMasterKey = validateMasterKey; + module.exports.getExpirationTime = getExpirationTime; + module.exports.validateDeviceType = validateDeviceType; +} diff --git a/spec/push.spec.js b/spec/push.spec.js new file mode 100644 index 0000000000..ba5b533bbe --- /dev/null +++ b/spec/push.spec.js @@ -0,0 +1,206 @@ +var push = require('../push'); + +describe('push', () => { + it('can check valid master key of request', (done) => { + // Make mock request + var request = { + info: { + masterKey: 'masterKey' + }, + config: { + masterKey: 'masterKey' + } + } + + expect(() => { + push.validateMasterKey(request); + }).not.toThrow(); + done(); + }); + + it('can check invalid master key of request', (done) => { + // Make mock request + var request = { + info: { + masterKey: 'masterKey' + }, + config: { + masterKey: 'masterKeyAgain' + } + } + + expect(() => { + push.validateMasterKey(request); + }).toThrow(); + done(); + }); + + it('can get query condition when channels is set', (done) => { + // Make mock request + var request = { + body: { + channels: ['Giants', 'Mets'] + } + } + + var where = push.getQueryCondition(request); + expect(where).toEqual({ + 'channels': { + '$in': ['Giants', 'Mets'] + } + }); + done(); + }); + + it('can get query condition when where is set', (done) => { + // Make mock request + var request = { + body: { + 'where': { + 'injuryReports': true + } + } + } + + var where = push.getQueryCondition(request); + expect(where).toEqual({ + 'injuryReports': true + }); + done(); + }); + + it('can get query condition when nothing is set', (done) => { + // Make mock request + var request = { + body: { + } + } + + expect(function() { + push.getQueryCondition(request); + }).toThrow(); + done(); + }); + + it('can throw on getQueryCondition when channels and where are set', (done) => { + // Make mock request + var request = { + body: { + 'channels': { + '$in': ['Giants', 'Mets'] + }, + 'where': { + 'injuryReports': true + } + } + } + + expect(function() { + push.getQueryCondition(request); + }).toThrow(); + done(); + }); + + it('can validate device type when no device type is set', (done) => { + // Make query condition + var where = { + } + + expect(function(){ + push.validateDeviceType(where); + }).not.toThrow(); + done(); + }); + + it('can validate device type when single valid device type is set', (done) => { + // Make query condition + var where = { + 'deviceType': 'ios' + } + + expect(function(){ + push.validateDeviceType(where); + }).not.toThrow(); + done(); + }); + + it('can validate device type when multiple valid device types are set', (done) => { + // Make query condition + var where = { + 'deviceType': { + '$in': ['android', 'ios'] + } + } + + expect(function(){ + push.validateDeviceType(where); + }).not.toThrow(); + done(); + }); + + it('can throw on validateDeviceType when single invalid device type is set', (done) => { + // Make query condition + var where = { + 'deviceType': 'osx' + } + + expect(function(){ + push.validateDeviceType(where); + }).toThrow(); + done(); + }); + + it('can throw on validateDeviceType when single invalid device type is set', (done) => { + // Make query condition + var where = { + 'deviceType': 'osx' + } + + expect(function(){ + push.validateDeviceType(where) + }).toThrow(); + done(); + }); + + it('can get expiration time in string format', (done) => { + // Make mock request + var timeStr = '2015-03-19T22:05:08Z'; + var request = { + body: { + 'expiration_time': timeStr + } + } + + var time = push.getExpirationTime(request); + expect(time).toEqual(new Date(timeStr).valueOf()); + done(); + }); + + it('can get expiration time in number format', (done) => { + // Make mock request + var timeNumber = 1426802708; + var request = { + body: { + 'expiration_time': timeNumber + } + } + + var time = push.getExpirationTime(request); + expect(time).toEqual(timeNumber * 1000); + done(); + }); + + it('can throw on getExpirationTime in invalid format', (done) => { + // Make mock request + var request = { + body: { + 'expiration_time': 'abcd' + } + } + + expect(function(){ + push.getExpirationTime(request); + }).toThrow(); + done(); + }); +}); From 99530971e10881cd59d5a83f8c11afdb1512ccfb Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 5 Feb 2016 11:58:52 -0800 Subject: [PATCH 12/23] Update CONTRIBUTING.md --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index faaae4e388..35920404ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,9 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Please Do's -* Please write tests to cover new methods. -* Please run the tests and make sure you didn't break anything. +* Take testing seriously! Aim to increase the test coverage with every pull request. +* Run the tests for the file you are working on with `TESTING=1 ./node_modules/jasmine/bin/jasmine.js --filter="MyFile"` (from repo root) +* Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html ##### Code of Conduct From 9cc091e1158a537c2500021d1ea149e8c2fa0578 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 5 Feb 2016 12:52:14 -0800 Subject: [PATCH 13/23] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35920404ee..23e6107df2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Please Do's * Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `TESTING=1 ./node_modules/jasmine/bin/jasmine.js --filter="MyFile"` (from repo root) +* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/ParseFile.spec.js` * Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html ##### Code of Conduct From fe933a77f4c2a26aaa7dd564ca0dbddff9bc6983 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 5 Feb 2016 12:52:36 -0800 Subject: [PATCH 14/23] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23e6107df2..397621264d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Please Do's * Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/ParseFile.spec.js` +* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/MyFile.spec.js` * Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html ##### Code of Conduct From e738d8ad6c9dd85d2a29beb9d96b6106a3a40e06 Mon Sep 17 00:00:00 2001 From: "Peter J. Shin" Date: Fri, 5 Feb 2016 13:31:10 -0800 Subject: [PATCH 15/23] Adding link to parse-server-example Thought it would be helpful to expose the example. It feels hidden in the docs right now. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index edfecea608..bcbe61d5ce 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Read the migration guide here: https://parse.com/docs/server/guide#migrating There is a development wiki here on GitHub: https://github.com/ParsePlatform/parse-server/wiki +We also have an [example project](https://github.com/ParsePlatform/parse-server-example) using the parse-server module on Express. + --- #### Basic options: From c1d688d5d5cc30d9849d628152b72658f7cc474d Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 5 Feb 2016 13:42:15 -0800 Subject: [PATCH 16/23] Remove default master key. If there is a default master key, people will inevitably use it. And that would be bad. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bcbe61d5ce..e11bb35105 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ var api = new ParseServer({ databaseURI: 'mongodb://localhost:27017/dev', cloud: '/home/myApp/cloud/main.js', // Provide an absolute path appId: 'myAppId', - masterKey: 'mySecretMasterKey', + masterKey: '', //Add your master key here. Keep it secret! fileKey: 'optionalFileKey', serverURL: 'http://localhost:' + port + '/parse' // Don't forget to change to https if needed }); From 23d2bdbcad6c9207952764762de66a92788e510e Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Wed, 3 Feb 2016 13:38:41 -0800 Subject: [PATCH 17/23] Add GCM client --- GCM.js | 82 ++++++++++++++++++++++++++++ package.json | 3 +- push.js | 3 +- spec/GCM.spec.js | 137 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 GCM.js create mode 100644 spec/GCM.spec.js diff --git a/GCM.js b/GCM.js new file mode 100644 index 0000000000..b9d5c728d7 --- /dev/null +++ b/GCM.js @@ -0,0 +1,82 @@ +var Parse = require('parse/node').Parse; +var gcm = require('node-gcm'); +var randomstring = require('randomstring'); + +var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks +var GCMRegistrationTokensMax = 1000; + +function GCM(apiKey) { + this.sender = new gcm.Sender(apiKey); +} + +/** + * Send gcm request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} registrationTokens A array of registration tokens + * @returns {Object} A promise which is resolved after we get results from gcm + */ +GCM.prototype.send = function (data, registrationTokens) { + if (registrationTokens.length >= GCMRegistrationTokensMax) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Too many registration tokens for a GCM request.'); + } + var pushId = randomstring.generate({ + length: 10, + charset: 'alphanumeric' + }); + var timeStamp = Date.now(); + var expirationTime; + // We handle the expiration_time convertion in push.js, so expiration_time is a valid date + // in Unix epoch time in milliseconds here + if (data['expiration_time']) { + expirationTime = data['expiration_time']; + } + // Generate gcm payload + var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + // Make and send gcm request + var message = new gcm.Message(gcmPayload); + var promise = new Parse.Promise(); + this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) { + // TODO: Use the response from gcm to generate and save push report + // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + promise.resolve(); + }); + return promise; +} + +/** + * Generate the gcm payload from the data we get from api request. + * @param {Object} coreData The data field under api request body + * @param {String} pushId A random string + * @param {Number} timeStamp A number whose format is the Unix Epoch + * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined + * @returns {Object} A promise which is resolved after we get results from gcm + */ +var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { + var payloadData = { + 'time': new Date(timeStamp).toISOString(), + 'push_id': pushId, + 'data': JSON.stringify(coreData) + } + var payload = { + priority: 'normal', + data: payloadData + }; + if (expirationTime) { + // The timeStamp and expiration is in milliseconds but gcm requires second + var timeToLive = Math.floor((expirationTime - timeStamp) / 1000); + if (timeToLive < 0) { + timeToLive = 0; + } + if (timeToLive >= GCMTimeToLiveMax) { + timeToLive = GCMTimeToLiveMax; + } + payload.timeToLive = timeToLive; + } + return payload; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + GCM.generateGCMPayload = generateGCMPayload; +} +module.exports = GCM; diff --git a/package.json b/package.json index 8486245d87..3d145ee40b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "mongodb": "~2.1.0", "multer": "^1.1.0", "parse": "^1.7.0", - "moment": "^2.11.1", + "randomstring": "^1.1.3", + "node-gcm": "^0.14.0", "request": "^2.65.0" }, "devDependencies": { diff --git a/push.js b/push.js index e7f0b9ca45..29a6a944e5 100644 --- a/push.js +++ b/push.js @@ -2,8 +2,7 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'), - moment = require('moment'); + rest = require('./rest'); var validPushTypes = ['ios', 'android']; diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js new file mode 100644 index 0000000000..d7484b0ea9 --- /dev/null +++ b/spec/GCM.spec.js @@ -0,0 +1,137 @@ +var GCM = require('../GCM'); + +describe('GCM', () => { + it('can generate GCM Payload without expiration time', (done) => { + //Mock request data + var data = { + 'alert': 'alert' + }; + var pushId = 1; + var timeStamp = 1454538822113; + var timeStampISOStr = new Date(timeStamp).toISOString(); + + var payload = GCM.generateGCMPayload(data, pushId, timeStamp); + + expect(payload.priority).toEqual('normal'); + expect(payload.timeToLive).toEqual(undefined); + var dataFromPayload = payload.data; + expect(dataFromPayload.time).toEqual(timeStampISOStr); + expect(dataFromPayload['push_id']).toEqual(pushId); + var dataFromUser = JSON.parse(dataFromPayload.data); + expect(dataFromUser).toEqual(data); + done(); + }); + + it('can generate GCM Payload with valid expiration time', (done) => { + //Mock request data + var data = { + 'alert': 'alert' + }; + var pushId = 1; + var timeStamp = 1454538822113; + var timeStampISOStr = new Date(timeStamp).toISOString(); + var expirationTime = 1454538922113 + + var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + + expect(payload.priority).toEqual('normal'); + expect(payload.timeToLive).toEqual(Math.floor((expirationTime - timeStamp) / 1000)); + var dataFromPayload = payload.data; + expect(dataFromPayload.time).toEqual(timeStampISOStr); + expect(dataFromPayload['push_id']).toEqual(pushId); + var dataFromUser = JSON.parse(dataFromPayload.data); + expect(dataFromUser).toEqual(data); + done(); + }); + + it('can generate GCM Payload with too early expiration time', (done) => { + //Mock request data + var data = { + 'alert': 'alert' + }; + var pushId = 1; + var timeStamp = 1454538822113; + var timeStampISOStr = new Date(timeStamp).toISOString(); + var expirationTime = 1454538822112; + + var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + + expect(payload.priority).toEqual('normal'); + expect(payload.timeToLive).toEqual(0); + var dataFromPayload = payload.data; + expect(dataFromPayload.time).toEqual(timeStampISOStr); + expect(dataFromPayload['push_id']).toEqual(pushId); + var dataFromUser = JSON.parse(dataFromPayload.data); + expect(dataFromUser).toEqual(data); + done(); + }); + + it('can generate GCM Payload with too late expiration time', (done) => { + //Mock request data + var data = { + 'alert': 'alert' + }; + var pushId = 1; + var timeStamp = 1454538822113; + var timeStampISOStr = new Date(timeStamp).toISOString(); + var expirationTime = 2454538822113; + + var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + + expect(payload.priority).toEqual('normal'); + // Four week in second + expect(payload.timeToLive).toEqual(4 * 7 * 24 * 60 * 60); + var dataFromPayload = payload.data; + expect(dataFromPayload.time).toEqual(timeStampISOStr); + expect(dataFromPayload['push_id']).toEqual(pushId); + var dataFromUser = JSON.parse(dataFromPayload.data); + expect(dataFromUser).toEqual(data); + done(); + }); + + it('can send GCM request', (done) => { + var gcm = new GCM('apiKey'); + // Mock gcm sender + var sender = { + send: jasmine.createSpy('send') + }; + gcm.sender = sender; + // Mock data + var expirationTime = 2454538822113; + var data = { + 'expiration_time': expirationTime, + 'data': { + 'alert': 'alert' + } + } + // Mock registrationTokens + var registrationTokens = ['token']; + + var promise = gcm.send(data, registrationTokens); + expect(sender.send).toHaveBeenCalled(); + var args = sender.send.calls.first().args; + // It is too hard to verify message of gcm library, we just verify tokens and retry times + expect(args[1].registrationTokens).toEqual(registrationTokens); + expect(args[2]).toEqual(5); + done(); + }); + + it('can throw on sending when we have too many registration tokens', (done) => { + var gcm = new GCM('apiKey'); + // Mock gcm sender + var sender = { + send: jasmine.createSpy('send') + }; + gcm.sender = sender; + // Mock registrationTokens + var registrationTokens = []; + for (var i = 0; i <= 2000; i++) { + registrationTokens.push(i.toString()); + } + + expect(function() { + gcm.send({}, registrationTokens); + }).toThrow(); + done(); + }); +}); From 2b5eab4c6150e0f6702be6789e06d5c60cebf470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Sun, 31 Jan 2016 20:08:07 +0000 Subject: [PATCH 18/23] Added ability to add a validation function to a Cloud Code function --- functions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/functions.js b/functions.js index de6a2f2718..5c96c8a4d3 100644 --- a/functions.js +++ b/functions.js @@ -9,6 +9,7 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { + // Run the validator for this function first if (Parse.Cloud.Validators[req.params.functionName]) { var result = Parse.Cloud.Validators[req.params.functionName](req.body); if (!result) { From 7299fad2ab3e8b89aa7009e2fb326b713b7501de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Wed, 3 Feb 2016 21:51:40 +0000 Subject: [PATCH 19/23] Added function validation --- functions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/functions.js b/functions.js index 5c96c8a4d3..de6a2f2718 100644 --- a/functions.js +++ b/functions.js @@ -9,7 +9,6 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { - // Run the validator for this function first if (Parse.Cloud.Validators[req.params.functionName]) { var result = Parse.Cloud.Validators[req.params.functionName](req.body); if (!result) { From 001b9f760e894f50073121f6d4cd6d4b19b643c5 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Tue, 2 Feb 2016 19:51:40 -0800 Subject: [PATCH 20/23] Add push parameter checking and query installation --- package.json | 1 + push.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d145ee40b..9f5d0b2885 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "parse": "^1.7.0", "randomstring": "^1.1.3", "node-gcm": "^0.14.0", + "moment": "^2.11.1", "request": "^2.65.0" }, "devDependencies": { diff --git a/push.js b/push.js index 29a6a944e5..e7f0b9ca45 100644 --- a/push.js +++ b/push.js @@ -2,7 +2,8 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); + rest = require('./rest'), + moment = require('moment'); var validPushTypes = ['ios', 'android']; From ef89226e77030d3e1107de65282f706edb580f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Sun, 31 Jan 2016 20:08:07 +0000 Subject: [PATCH 21/23] Added ability to add a validation function to a Cloud Code function --- functions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/functions.js b/functions.js index de6a2f2718..5c96c8a4d3 100644 --- a/functions.js +++ b/functions.js @@ -9,6 +9,7 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { + // Run the validator for this function first if (Parse.Cloud.Validators[req.params.functionName]) { var result = Parse.Cloud.Validators[req.params.functionName](req.body); if (!result) { From f4c3ed533fa721ac7d52d06a4dc08bb6172b3929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kaiser?= Date: Wed, 3 Feb 2016 21:51:40 +0000 Subject: [PATCH 22/23] Added function validation --- functions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/functions.js b/functions.js index 5c96c8a4d3..de6a2f2718 100644 --- a/functions.js +++ b/functions.js @@ -9,7 +9,6 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { - // Run the validator for this function first if (Parse.Cloud.Validators[req.params.functionName]) { var result = Parse.Cloud.Validators[req.params.functionName](req.body); if (!result) { From 62f80547620f2eeea4107c315f14c2bfd1a15427 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 18:53:06 -0800 Subject: [PATCH 23/23] Address review comments --- Schema.js | 3 ++- schemas.js | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Schema.js b/Schema.js index 3f590ac7d2..25e301cdd9 100644 --- a/Schema.js +++ b/Schema.js @@ -76,7 +76,8 @@ function classNameIsValid(className) { className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. className === '_Role' || joinClassRegex.test(className) || - classAndFieldRegex.test(className) + //Class names have the same constraints as field names, but also allow the previous additional names. + fieldNameIsValid(className) ); } diff --git a/schemas.js b/schemas.js index 4a8d50b4ee..875967cde7 100644 --- a/schemas.js +++ b/schemas.js @@ -32,10 +32,10 @@ function mongoFieldTypeToSchemaAPIType(type) { function mongoSchemaAPIResponseFields(schema) { fieldNames = Object.keys(schema).filter(key => key !== '_id'); - response = {}; - fieldNames.forEach(fieldName => { - response[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]); - }); + response = fieldNames.reduce((obj, fieldName) => { + obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]) + return obj; + }, {}); response.ACL = {type: 'ACL'}; response.createdAt = {type: 'Date'}; response.updatedAt = {type: 'Date'};