diff --git a/.gitignore b/.gitignore index 318fed2034..a2522c164c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,11 @@ node_modules # WebStorm/IntelliJ .idea +# visual studio code +.vscode + # Babel.js lib/ + +# Cache +.cache diff --git a/README.md b/README.md index b1cfba4054..7600c18e3f 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,6 @@ var ParseServer = require('parse-server').ParseServer; var app = express(); -var port = process.env.PORT || 1337; - // Specify the connection string for your mongodb database // and the location to your Parse cloud code var api = new ParseServer({ @@ -138,6 +136,7 @@ app.get('/', function(req, res) { res.status(200).send('Express is running here.'); }); +var port = process.env.PORT || 1337; app.listen(port, function() { console.log('parse-server-example running on port ' + port + '.'); }); @@ -171,6 +170,86 @@ Alernatively, you can use the `PARSE_SERVER_OPTIONS` environment variable set to To start the server, just run `npm start`. + +#### Configuration file + +You can pass a configuration JSON file to npm start: + +`$ npm start -- --config path/to/config.json` + +(note that the first `--` is the required format by npm) + +#### Multiple applications + +You can host mutiple applications on the same server by specifying as options or use a config JSON; + +``` +{ + "applications": [ + { + "appId": "APP1", + "masterKey": "MASTERKEY1", + ... + }, + { + "appId": "APP2", + "masterKey": "MASTERKEY2", + ... + }, + // General adapters configuration (optional) + // It's overriden by specific configuration + databaseAdapter: "...", + filesAdatpter: "..." + ] +} +``` + +Use `$ npm start -- --config path/to/config.json` to start the server + + +:+1: if you use the `PARSE_SERVER_OPTIONS` environment variable, the multiple applications support will be granted too. + +:warning: Make sure to use different databases for each app. The behaviour could be unexpected otherwise. + +##### Cloud Code for multiple applications + +Cloud code will run in a separate node process and use HTTP as a transport to register the hooks. + +``` +cloud: "path/to/main.js" +``` + +The cloud code server will start on port 8081 and will be incremented for each app. + + +You can specify a specific port for each of your cloud code: + +``` +cloud: { + main: "/path/to/main.js", + port: 12345, + forever: { + ... // (Options to pass to forever)[https://github.com/foreverjs/forever-monitor] + } +} +``` + +If you only have a single app, but pass an object for the cloud option, +this will be run in a separate process too. + +The other options available for Cloud Code are: + +`hooksCreationStrategy: "always" | "never" | "try"` + +* *always* will always use the last cloud code server +* *never* will not register the new hook +* *try* will register the hook if it doesn't exist + +##### Standalone Cloud Code Server + +please see (here)[https://github.com/ParsePlatform/parse-server/blob/master/src/cloud-code/README.md] + + ##### Global installation You can install parse-server globally @@ -179,6 +258,14 @@ You can install parse-server globally Now you can just run `$ parse-server` from your command line. +To pass a configuration file you can use `$ parse-server --config path/to/config.json` + + +#### Create a new set of keys + +run `$ ./bin/gen-keys` to generate a new set of keys for a new app. + +You can use the configuration provided with the json configuration. ### Supported diff --git a/bin/config.js b/bin/config.js new file mode 100644 index 0000000000..66a00a1e38 --- /dev/null +++ b/bin/config.js @@ -0,0 +1,62 @@ +var path = require("path"); +function loadFromCommandLine(args) { + args = args || []; + while (args.length > 0) { + if (args[0] == "--config") { + if (args.length < 2) { + throw "Please specify the configuration file (json)"; + } + return require(path.resolve(args[1])); + } + args = args.slice(1, args.length); + } +} + +function loadFromEnvironment(env) { + env = env || {}; + var options = {}; + if (env.PARSE_SERVER_OPTIONS) { + + options = JSON.parse(env.PARSE_SERVER_OPTIONS); + + } else { + + options.databaseURI = env.PARSE_SERVER_DATABASE_URI; + options.cloud = env.PARSE_SERVER_CLOUD_CODE_MAIN; + options.collectionPrefix = env.PARSE_SERVER_COLLECTION_PREFIX; + + // Keys and App ID + options.appId = env.PARSE_SERVER_APPLICATION_ID; + options.clientKey = env.PARSE_SERVER_CLIENT_KEY; + options.restAPIKey = env.PARSE_SERVER_REST_API_KEY; + options.dotNetKey = env.PARSE_SERVER_DOTNET_KEY; + options.javascriptKey = env.PARSE_SERVER_JAVASCRIPT_KEY; + options.dotNetKey = env.PARSE_SERVER_DOTNET_KEY; + options.masterKey = env.PARSE_SERVER_MASTER_KEY; + options.fileKey = env.PARSE_SERVER_FILE_KEY; + // Comma separated list of facebook app ids + var facebookAppIds = env.PARSE_SERVER_FACEBOOK_APP_IDS; + + if (facebookAppIds) { + facebookAppIds = facebookAppIds.split(","); + options.facebookAppIds = facebookAppIds; + } + var oauth = process.env.PARSE_SERVER_OAUTH_PROVIDERS; + if (oauth) { + options.oauth = JSON.parse(oauth); + }; + } + return options; +} + + +module.exports = function() { + var options = loadFromCommandLine(process.argv); + if (typeof options == "undefined") { + options = loadFromEnvironment(process.env); + } + return options; +} + +module.exports.loadFromEnvironment = loadFromEnvironment; +module.exports.loadFromCommandLine = loadFromCommandLine; \ No newline at end of file diff --git a/bin/gen-keys b/bin/gen-keys new file mode 100755 index 0000000000..0a6ca58746 --- /dev/null +++ b/bin/gen-keys @@ -0,0 +1,15 @@ +#!/usr/bin/env node +var rack = require('hat').rack(); + +var newApp = { + "appId": rack(), + "masterKey": rack(), + "clientKey": rack(), + "javascriptKey": rack(), + "dotNetKey": rack(), + "restAPIKey": rack(), + "collectionPrefix": "", + "databaseURI": "" +}; + +process.stdout.write(JSON.stringify(newApp, null, 4)+"\n"); \ No newline at end of file diff --git a/bin/parse-server b/bin/parse-server index 66d010414e..69a9bb248c 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,43 +1,13 @@ #!/usr/bin/env node +var path = require("path"); var express = require('express'); var ParseServer = require("../lib/index").ParseServer; +var options = require("./config")(); var app = express(); -var options = {}; -if (process.env.PARSE_SERVER_OPTIONS) { - - options = JSON.parse(process.env.PARSE_SERVER_OPTIONS); - -} else { - - options.databaseURI = process.env.PARSE_SERVER_DATABASE_URI; - options.cloud = process.env.PARSE_SERVER_CLOUD_CODE_MAIN; - options.collectionPrefix = process.env.PARSE_SERVER_COLLECTION_PREFIX; - - // Keys and App ID - options.appId = process.env.PARSE_SERVER_APPLICATION_ID; - options.clientKey = process.env.PARSE_SERVER_CLIENT_KEY; - options.restAPIKey = process.env.PARSE_SERVER_REST_API_KEY; - options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY; - options.javascriptKey = process.env.PARSE_SERVER_JAVASCRIPT_KEY; - options.masterKey = process.env.PARSE_SERVER_MASTER_KEY; - options.fileKey = process.env.PARSE_SERVER_FILE_KEY; - // Comma separated list of facebook app ids - var facebookAppIds = process.env.PARSE_SERVER_FACEBOOK_APP_IDS; - - if (facebookAppIds) { - facebookAppIds = facebookAppIds.split(","); - options.facebookAppIds = facebookAppIds; - } - - var oauth = process.env.PARSE_SERVER_OAUTH_PROVIDERS; - if (oauth) { - options.oauth = JSON.parse(oauth); - }; -} - var mountPath = process.env.PARSE_SERVER_MOUNT_PATH || "/"; + var api = new ParseServer(options); app.use(mountPath, api); diff --git a/package.json b/package.json index a39b5ca1b6..52e222bb46 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,14 @@ "body-parser": "^1.14.2", "deepcopy": "^0.6.1", "express": "^4.13.4", + "forever-monitor": "^1.7.0", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", "node-gcm": "^0.14.0", "parse": "^1.7.0", + "parse-cloud-express": "~1.2.0", + "randomstring": "^1.1.3", "request": "^2.65.0", "winston": "^2.1.1" }, diff --git a/spec/ConfigurationLoading.spec.js b/spec/ConfigurationLoading.spec.js new file mode 100644 index 0000000000..5884c30b58 --- /dev/null +++ b/spec/ConfigurationLoading.spec.js @@ -0,0 +1,88 @@ +var configuration = require("./support/parse-server-config.json"); +var Parse = require("parse/node"); +var apps = configuration.applications; +var configLoader = require("../bin/config"); + +describe('Configuration loading', () => { + + it('should load a JSON from arguments', done => { + var config = configLoader.loadFromCommandLine(["--config", "./spec/support/parse-server-config.json"]); + expect(config).not.toBe(undefined); + expect(config.applications.length).toBe(2); + done(); + }); + + it('should throw when json does not exist', done => { + function load() { + return configLoader.loadFromCommandLine(["--config", "./spec/support/bar.json"]); + } + expect(load).toThrow(); + done(); + }); + + it('should throw when json is missing', done => { + function load() { + return configLoader.loadFromCommandLine(["--config"]); + } + expect(load).toThrow("Please specify the configuration file (json)"); + done(); + }); + + it('should retun nothing when nothing is specified', done => { + var config = configLoader.loadFromCommandLine(); + expect(config).toBe(undefined); + done(); + }); + + it('should support more arguments', done => { + var config = configLoader.loadFromCommandLine(["--some","--config", "./spec/support/parse-server-config.json", "--other"]); + expect(config).not.toBe(undefined); + expect(config.applications.length).toBe(2); + done(); + }); + + it('should load from environment', done => { + var env = { + PARSE_SERVER_DATABASE_URI: "", + PARSE_SERVER_CLOUD_CODE_MAIN: "", + PARSE_SERVER_COLLECTION_PREFIX: "", + PARSE_SERVER_APPLICATION_ID: "", + PARSE_SERVER_CLIENT_KEY: "", + PARSE_SERVER_REST_API_KEY: "", + PARSE_SERVER_DOTNET_KEY: "", + PARSE_SERVER_JAVASCRIPT_KEY: "", + PARSE_SERVER_DOTNET_KEY: "", + PARSE_SERVER_MASTER_KEY: "", + PARSE_SERVER_FILE_KEY: "", + PARSE_SERVER_FACEBOOK_APP_IDS: "hello,world" + } + + var config = configLoader.loadFromEnvironment(env); + expect(config).not.toBe(undefined); + expect(Object.keys(config).length).toBe(Object.keys(env).length); + expect(config.facebookAppIds.length).toBe(2); + expect(config.facebookAppIds).toContain("hello"); + expect(config.facebookAppIds).toContain("world"); + done(); + }); + + it('should load from environment options', done => { + var env = { + PARSE_SERVER_OPTIONS: require("fs").readFileSync("./spec/support/parse-server-config.json") + } + + var config = configLoader.loadFromEnvironment(env); + expect(config).not.toBe(undefined); + expect(config.applications.length).toBe(2); + done(); + }); + + it('should load empty configuration options', done => { + var config = configLoader(); + expect(config).not.toBe(undefined); + expect(config).not.toBe({}); + expect(config.appId).toBe(undefined); + done(); + }); + +}); \ No newline at end of file diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index 370550c0a5..8b8869ed20 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -786,7 +786,11 @@ describe('Parse.ACL', () => { equal(results.length, 1); var result = results[0]; ok(result); - equal(result.id, object.id); + if (!result) { + fail("should have result"); + } else { + equal(result.id, object.id); + } done(); } }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 52c17fbfed..4ec821eccd 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -274,7 +274,11 @@ describe('miscellaneous', function() { var objAgain = new Parse.Object('BeforeDeleteFail', {objectId: id}); return objAgain.fetch(); }).then((objAgain) => { - expect(objAgain.get('foo')).toEqual('bar'); + if (objAgain) { + expect(objAgain.get('foo')).toEqual('bar'); + } else { + fail("unable to fetch the object ", id); + } done(); }, (error) => { // We should have been able to fetch the object again @@ -350,6 +354,11 @@ describe('miscellaneous', function() { it('test cloud function return types', function(done) { Parse.Cloud.run('foo').then((result) => { expect(result.object instanceof Parse.Object).toBeTruthy(); + if (!result.object) { + fail("Unable to run foo"); + done(); + return; + } expect(result.object.className).toEqual('Foo'); expect(result.object.get('x')).toEqual(2); var bar = result.object.get('relation'); @@ -380,7 +389,10 @@ describe('miscellaneous', function() { expect(results.length).toEqual(1); expect(results[0]['foo']).toEqual('bar'); done(); - }); + }).fail( err => { + fail(err); + done(); + }) }); it('test beforeSave get full object on create and update', function(done) { @@ -417,7 +429,8 @@ describe('miscellaneous', function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); // Clear mock beforeSave - delete Parse.Cloud.Triggers.beforeSave.GameScore; + + Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); done(); }, function(error) { fail(error); @@ -459,9 +472,10 @@ describe('miscellaneous', function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); // Clear mock afterSave - delete Parse.Cloud.Triggers.afterSave.GameScore; + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, function(error) { + console.error(error); fail(error); done(); }); @@ -511,7 +525,7 @@ describe('miscellaneous', function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); // Clear mock beforeSave - delete Parse.Cloud.Triggers.beforeSave.GameScore; + Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); done(); }, function(error) { fail(error); @@ -563,9 +577,10 @@ describe('miscellaneous', function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); // Clear mock afterSave - delete Parse.Cloud.Triggers.afterSave.GameScore; + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, function(error) { + console.error(error); fail(error); done(); }); @@ -578,12 +593,12 @@ describe('miscellaneous', function() { }); Parse.Cloud.run('willFail').then((s) => { fail('Should not have succeeded.'); - delete Parse.Cloud.Functions['willFail']; + Parse.Cloud._removeHook("Functions", "willFail"); done(); }, (e) => { expect(e.code).toEqual(141); expect(e.message).toEqual('noway'); - delete Parse.Cloud.Functions['willFail']; + Parse.Cloud._removeHook("Functions", "willFail"); done(); }); }); @@ -612,7 +627,7 @@ describe('miscellaneous', function() { // Make sure query string params override body params expect(res.other).toEqual('2'); expect(res.foo).toEqual("bar"); - delete Parse.Cloud.Functions['echoParams']; + Parse.Cloud._removeHook("Functions",'echoParams'); done(); }); }); @@ -626,7 +641,7 @@ describe('miscellaneous', function() { }); Parse.Cloud.run('functionWithParameterValidation', {"success":100}).then((s) => { - delete Parse.Cloud.Functions['functionWithParameterValidation']; + Parse.Cloud._removeHook("Functions", "functionWithParameterValidation"); done(); }, (e) => { fail('Validation should not have failed.'); @@ -644,7 +659,7 @@ describe('miscellaneous', function() { Parse.Cloud.run('functionWithParameterValidationFailure', {"success":500}).then((s) => { fail('Validation should not have succeeded'); - delete Parse.Cloud.Functions['functionWithParameterValidationFailure']; + Parse.Cloud._removeHook("Functions", "functionWithParameterValidationFailure"); done(); }, (e) => { expect(e.code).toEqual(141); diff --git a/spec/ParseHooks.spec.js b/spec/ParseHooks.spec.js new file mode 100644 index 0000000000..19fca2c4b6 --- /dev/null +++ b/spec/ParseHooks.spec.js @@ -0,0 +1,233 @@ +/* global describe, it, expect, fail, Parse */ +var request = require('request'); +// Inject the hooks API +Parse.Hooks = require("../src/cloud-code/Parse.Hooks"); + +describe('Hooks', () => { + + it("should have some hooks registered", (done) => { + Parse.Hooks.getFunctions().then((res) => { + expect(res.constructor).toBe(Array.prototype.constructor); + done(); + }, (err) => { + fail(err); + done(); + }); + }); + + it("should have some triggers registered", (done) => { + Parse.Hooks.getTriggers().then( (res) => { + expect(res.constructor).toBe(Array.prototype.constructor); + done(); + }, (err) => { + fail(err); + done(); + }); + }); + + it("should CRUD a function registration", (done) => { + // Create + Parse.Hooks.createFunction("My-Test-Function", "http://someurl").then((res) => { + expect(res.functionName).toBe("My-Test-Function"); + expect(res.url).toBe("http://someurl") + // Find + return Parse.Hooks.getFunction("My-Test-Function"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + expect(res).not.toBe(null); + expect(res).not.toBe(undefined); + expect(res.url).toBe("http://someurl"); + // delete + return Parse.Hooks.updateFunction("My-Test-Function", "http://anotherurl"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + expect(res.functionName).toBe("My-Test-Function"); + expect(res.url).toBe("http://anotherurl") + + return Parse.Hooks.deleteFunction("My-Test-Function"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + // Find again! but should be deleted + return Parse.Hooks.getFunction("My-Test-Function"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + fail("Should not succeed") + done(); + }, (err) => { + expect(err).not.toBe(null); + expect(err).not.toBe(undefined); + expect(err.code).toBe(143); + expect(err.error).toBe("no function named: My-Test-Function is defined") + done(); + }) + }); + + it("should CRUD a trigger registration", (done) => { + // Create + Parse.Hooks.createTrigger("MyClass","beforeDelete", "http://someurl").then((res) => { + expect(res.className).toBe("MyClass"); + expect(res.triggerName).toBe("beforeDelete"); + expect(res.url).toBe("http://someurl") + // Find + return Parse.Hooks.getTrigger("MyClass","beforeDelete"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + expect(res).not.toBe(null); + expect(res).not.toBe(undefined); + expect(res.url).toBe("http://someurl"); + // delete + return Parse.Hooks.updateTrigger("MyClass","beforeDelete", "http://anotherurl"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + expect(res.className).toBe("MyClass"); + expect(res.url).toBe("http://anotherurl") + + return Parse.Hooks.deleteTrigger("MyClass","beforeDelete"); + }, (err) => { + fail(err); + done(); + }).then((res) => { + // Find again! but should be deleted + return Parse.Hooks.getTrigger("MyClass","beforeDelete"); + }, (err) => { + fail(err); + done(); + }).then(function(){ + fail("should not succeed"); + done(); + }, (err) => { + expect(err).not.toBe(null); + expect(err).not.toBe(undefined); + expect(err.code).toBe(143); + expect(err.error).toBe("class MyClass does not exist") + done(); + }); + }); + + it("should fail to register hooks without Master Key", (done) => { + request.post(Parse.serverURL+"/hooks/functions", { + headers: { + "X-Parse-Application-Id": Parse.applicationId, + "X-Parse-Javascript-Key": Parse.javascriptKey, + }, + body: JSON.stringify({ url: "http://hello.word", functionName: "SomeFunction"}) + }, (err, res, body) => { + body = JSON.parse(body); + expect(body.error).toBe("unauthorized"); + expect(res.statusCode).toBe(403); + done(); + }) + }); + + it("should fail trying to create two times the same function", (done) => { + Parse.Hooks.createFunction("my_new_function", "http://url.com").then( () => { + return Parse.Hooks.createFunction("my_new_function", "http://url.com") + }, () => { + fail("should create a new function"); + }).then( () => { + fail("should not be able to create the same function"); + }, (err) => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + expect(err.code).toBe(143); + expect(err.error).toBe('function name: my_new_function already exits') + return Parse.Hooks.deleteFunction("my_new_function"); + }).then(() => { + done(); + }, (err) => { + fail(err); + done(); + }) + }); + + it("should fail trying to create two times the same trigger", (done) => { + Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com").then( () => { + return Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com") + }, () => { + fail("should create a new trigger"); + }).then( () => { + fail("should not be able to create the same trigger"); + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe('class MyClass already has trigger beforeSave') + return Parse.Hooks.deleteTrigger("MyClass", "beforeSave"); + }).then(() => { + done(); + }, (err) => { + fail(err); + done(); + }) + }); + + it("should fail trying to update a function that don't exist", (done) => { + Parse.Hooks.updateFunction("A_COOL_FUNCTION", "http://url.com").then( () => { + fail("Should not succeed") + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe('no function named: A_COOL_FUNCTION is defined'); + return Parse.Hooks.getFunction("A_COOL_FUNCTION") + }).then( (res) => { + fail("the function should not exist"); + done(); + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe('no function named: A_COOL_FUNCTION is defined'); + done(); + }); + }); + + it("should fail trying to update a trigger that don't exist", (done) => { + Parse.Hooks.updateTrigger("AClassName","beforeSave", "http://url.com").then( () => { + fail("Should not succeed") + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe('class AClassName does not exist'); + return Parse.Hooks.getTrigger("AClassName","beforeSave") + }).then( (res) => { + fail("the function should not exist"); + done(); + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe('class AClassName does not exist'); + done(); + }); + }); + + + it("should fail trying to create a malformed function", (done) => { + Parse.Hooks.createFunction("MyFunction").then( (res) => { + fail(res); + }, (err) => { + expect(err.code).toBe(143); + expect(err.error).toBe("invalid hook declaration"); + done(); + }); + }); + + it("should fail trying to create a malformed function (REST)", (done) => { + request.post(Parse.serverURL+"/hooks/functions", { + headers: { + "X-Parse-Application-Id": Parse.applicationId, + "X-Parse-Master-Key": Parse.masterKey, + }, + body: JSON.stringify({ functionName: "SomeFunction"}) + }, (err, res, body) => { + body = JSON.parse(body); + expect(body.error).toBe("invalid hook declaration"); + expect(body.code).toBe(143); + done(); + }) + }); +}); \ No newline at end of file diff --git a/spec/ParseServer-Cloud.spec.js b/spec/ParseServer-Cloud.spec.js new file mode 100644 index 0000000000..a9d4251dc1 --- /dev/null +++ b/spec/ParseServer-Cloud.spec.js @@ -0,0 +1,314 @@ +var configuration = require("./support/parse-server-config.json"); +var Parse = require("parse/node"); +var apps = configuration.applications; +var configLoader = require("../bin/config"); +var Server = require("../src/cloud-code"); +var ParseCloud = require("../src/cloud-code/Parse.Cloud"); +Parse.Hooks = require("../src/cloud-code/Parse.Hooks"); +var jsonCacheDir = "./.cache"; +var express = require("express"); +var databaseURI = process.env.DATABASE_URI; +var ParseServer = require('../src/index').ParseServer; + + +var port = 8379; +var serverURL = 'http://localhost:' + port + '/1'; + +for(var i in configuration.applications) { + configuration.applications[i].serverURL = serverURL; +} + +var app = express(); +var server = app.listen(port); + +// Set up an API server for testing +var api = new ParseServer(configuration); +app.use('/1', api); + +function use(app) { + Parse.initialize(app.appId || app.applicationId, app.javascriptKey, app.masterKey); + Parse.serverURL = app.serverURL; +} + +var shouldWait = process.env.WAIT_FOR_SERVER; + +describe('Multi Server Testing', () => { + beforeEach((done) => { + // Set the proper Pare serverURL + use(apps[0]); + if (shouldWait) { + shouldWait = false; + setTimeout(() => { + done(); + }, 500); + } else { + done(); + } + }) + it('first app should have hello', done => { + Parse.initialize(apps[0].appId, apps[0].javascriptKey, apps[0].masterKey); + Parse.Cloud.run('hello', {}, (result, error) => { + expect(result).toEqual('Hello world!'); + done(); + }); + }); + + it('second app should have hello', done => { + use(apps[1]); + Parse.Cloud.run('hello', {}, (result, error) => { + expect(result).toEqual('Hello world'); + console.error(error); + done(); + }); + }); + + it('should echo the right application ID', done => { + var hit = 0; + function doneIfNeeded() { + hit++; + if (hit != 2) { + return; + } + done(); + } + use(apps[1]); + Parse.Cloud.run('echoParseKeys', {}).then((result) => { + expect(result.applicationId).toEqual(apps[1].appId); + expect(result.javascriptKey).toEqual(apps[1].javascriptKey); + expect(result.masterKey).toEqual(apps[1].masterKey); + use(apps[1]); + doneIfNeeded(); + }, (error) => { + console.error(error); + fail(JSON.stringify(error)); + doneIfNeeded(); + }); + + use(apps[0]); + Parse.Cloud.run('echoParseKeys', {}).then((result) => { + fail("This function should not be defined"); + doneIfNeeded(); + }, (error) => { + + doneIfNeeded(); + }); + }); + + it('should delete the proper hook and not leak', done => { + + use(apps[1]); + + Parse.Cloud.run('echoParseKeys', {}).then( (result) => { + expect(result.applicationId).toEqual(apps[1].appId); + expect(result.javascriptKey).toEqual(apps[1].javascriptKey); + expect(result.masterKey).toEqual(apps[1].masterKey); + done(); + }).fail( (err) => { + expect(err.code).toEqual(141); + done(); + }); + + }); + + it('should create the proper beforeSave and set the proper app ID', done => { + + use(apps[1]); + var obj = new Parse.Object('InjectAppId'); + return obj.save().then( () => { + var query = new Parse.Query('InjectAppId'); + query.get(obj.id).then( (objAgain) => { + expect(objAgain.get('applicationId')).toEqual(apps[1].appId); + expect(objAgain.get('javascriptKey')).toEqual(apps[1].javascriptKey); + expect(objAgain.get('masterKey')).toEqual(apps[1].masterKey); + done(); + }, (error) => { + fail("Failed getting object"); + fail(JSON.stringify(error)); + done(); + }); + }, (error) => { + fail("Failed saving obj"); + fail(JSON.stringify(error)); + done(); + }); + + }); + + it('should create an object in the proper DB (and not the other)', done => { + + use(apps[1]); + var obj = new Parse.Object('SomeObject'); + obj.save().then( () => { + var query = new Parse.Query('SomeObject'); + return query.get(obj.id); + }, (error) => { + fail(error); + done(); + }).then( (objAgain) => { + + expect(objAgain).not.toBeUndefined(); + expect(objAgain.id).toEqual(obj.id); + + // Check if the data exists in another app + Parse.initialize(apps[0].appId, apps[0].javascriptKey, apps[0].masterKey); + var q = new Parse.Query('SomeObject'); + return q.find(); + + }, (error) => { + fail(error); + done(); + }).then( (result) => { + expect(result.constructor).toBe(Array.prototype.constructor); + expect(result.length).toBe(0); + done(); + }, (error) => { + fail(error); + done(); + }); + + }); + + it('should create a proper cloud code server for an existing parse APP', done => { + // Start a cloud code server for APP 1. + var config = { + applicationId: apps[1].appId, + javascriptKey: apps[1].javascriptKey, + masterKey: apps[1].masterKey, + port: 12355, + main: "../cloud/main-2.js", + serverURL: serverURL, + hooksCreationStrategy: "always" + }; + + var server = new Server(config); + + Parse.Cloud.define("myCloud", (req, res) => { + res.success("code!"); + }).then( () => { + Parse.Cloud.run("myCloud", {}, (result, error) => { + if (error) { + fail(error); + } + expect(result).toEqual('code!'); + server.close(); + done(); + }); + }, (err) => { + fail(err); + server.close(); + done(); + }); + + }); + + it('test beforeSave on custom Cloud Code (create update)', (done) => { + + // Start a cloud code server for APP 1. + var config = { + applicationId: apps[1].appId, + javascriptKey: apps[1].javascriptKey, + masterKey: apps[1].masterKey, + port: 12346, + main: "../cloud/main.js", + serverURL: serverURL, + hooksCreationStrategy: "always" + }; + var server = new Server(config); + + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.beforeSave('GameScore', (req, res) => { + var object = req.object; + // TODO: The Parse objects are different in CC execution + // Because it comes from parse-cloud-express + // expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('fooAgain')).toEqual('barAgain'); + expect(object.id).not.toBeUndefined(); + expect(object.createdAt).not.toBeUndefined(); + expect(object.updatedAt).not.toBeUndefined(); + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }).then( () => { + var obj = new Parse.Object('GameScore'); + obj.set('foo', 'bar'); + obj.set('fooAgain', 'barAgain'); + obj.save().then( () => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }).then( () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + // Clear mock beforeSave + Parse.Hooks.deleteTrigger('GameScore', 'beforeSave'); + server.close(); + done(); + }, (error) => { + fail(error); + server.close(); + done(); + }); + }, (err) => { + fail(JSON.strngify(err)); + server.close(); + done(); + }); + + }); + + it('should not create the hook', (done) => { + + // Start a cloud code server for APP 1. + var config = { + applicationId: apps[1].appId, + javascriptKey: apps[1].javascriptKey, + masterKey: apps[1].masterKey, + port: 12347, + main: "../cloud/main.js", + serverURL: serverURL, + hooksCreationStrategy: "always" + }; + var server = new Server(config); + + Parse.Cloud.define("hello_world", (req, res) => { + + fail("This shoud not be called!"); + res.success("Hello!"); + + }, "never") + .then( res => { + + expect(res).toBeUndefined(); + return Parse.Cloud.run("hello_world", {}); + + }, function(err){ + fail(err); + server.close(); + done(); + }).then( (res) => { + + expect(res).toBeUndefined(); + fail("Should not be defined"); + server.close(); + done(); + + }, (err) => { + + expect(err).not.toBeUndefined(); + expect(err.code).toBe(141); + expect(err.message).toBe('Invalid function.'); + server.close(); + done(); + + }); + }); +}); diff --git a/spec/helper.js b/spec/helper.js index 8b587f7d5d..fee57d9fa3 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -10,7 +10,8 @@ var ParseServer = require('../src/index').ParseServer; var databaseURI = process.env.DATABASE_URI; var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js'; - +var port = 8378; +var serverURL = 'http://localhost:' + port + '/1'; // Set up an API server for testing var api = new ParseServer({ databaseURI: databaseURI, @@ -33,12 +34,11 @@ var api = new ParseServer({ var app = express(); app.use('/1', api); -var port = 8378; + var server = app.listen(port); // Set up a Parse client to talk to our test API server var Parse = require('parse/node'); -Parse.serverURL = 'http://localhost:' + port + '/1'; // This is needed because we ported a bunch of tests from the non-A+ way. // TODO: update tests to work in an A+ way @@ -46,6 +46,7 @@ Parse.Promise.disableAPlusCompliant(); beforeEach(function(done) { Parse.initialize('test', 'test', 'test'); + Parse.serverURL = serverURL; Parse.User.enableUnsafeCurrentUser(); done(); }); @@ -221,3 +222,4 @@ global.expectError = expectError; global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; +global.mockFacebook = mockFacebook; \ No newline at end of file diff --git a/spec/support/parse-server-config.json b/spec/support/parse-server-config.json new file mode 100644 index 0000000000..f6f74ed638 --- /dev/null +++ b/spec/support/parse-server-config.json @@ -0,0 +1,24 @@ +{ + "applications": [ + { + "appId": "test2", + "javascriptKey": "test2", + "dotNetKey": "windows", + "clientKey": "client", + "restAPIKey": "rest", + "masterKey": "test2", + "collectionPrefix": "test_", + "fileKey": "test", + "databaseURI": "mongodb://localhost:27017/test2", + "cloud" : "./src/cloud/main.js" + }, + { + "appId": "multi-server-test", + "javascriptKey": "1234", + "masterKey": "testMasterKey", + "cloud" : "./src/cloud/main-2.js", + "databaseURI": "mongodb://localhost:27017/test-altername", + "fileKey": "multi-test" + } + ] +} diff --git a/src/Config.js b/src/Config.js index aeb25a6173..3b9188a483 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,9 @@ function Config(applicationId, mount) { this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.filesController = cacheInfo.filesController; - + this.pushController = cacheInfo.pushController; this.oauth = cacheInfo.oauth; + this.mount = mount; } diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 6fde54b766..f1238694aa 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -13,6 +13,13 @@ export class FilesController { this._filesAdapter = filesAdapter; } + static getHandler() { + return (req, res) => { + let config = new Config(req.params.appId); + return config.filesController.getHandler()(req, res); + } + } + getHandler() { return (req, res) => { let config = new Config(req.params.appId); @@ -30,6 +37,13 @@ export class FilesController { }; } + static createHandler() { + return (req, res, next) => { + let config = req.config; + return config.filesController.createHandler()(req, res, next); + } + } + createHandler() { return (req, res, next) => { if (!req.body || !req.body.length) { @@ -50,6 +64,7 @@ export class FilesController { return; } + const filesController = req.config.filesController; // If a content-type is included, we'll add an extension so we can // return the same content-type. let extension = ''; @@ -60,9 +75,9 @@ export class FilesController { } let filename = randomHexString(32) + '_' + req.params.filename + extension; - this._filesAdapter.createFile(req.config, filename, req.body).then(() => { + filesController._filesAdapter.createFile(req.config, filename, req.body).then(() => { res.status(201); - var location = this._filesAdapter.getFileLocation(req.config, filename); + var location = filesController._filesAdapter.getFileLocation(req.config, filename); res.set('Location', location); res.json({ url: location, name: filename }); }).catch((error) => { @@ -72,6 +87,13 @@ export class FilesController { }; } + static deleteHandler() { + return (req, res, next) => { + let config = req.config; + return config.filesController.deleteHandler()(req, res, next); + } + } + deleteHandler() { return (req, res, next) => { this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => { @@ -114,9 +136,9 @@ export class FilesController { } } - getExpressRouter() { + static getExpressRouter() { let router = express.Router(); - router.get('/files/:appId/:filename', this.getHandler()); + router.get('/files/:appId/:filename', FilesController.getHandler()); router.post('/files', function(req, res, next) { next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, @@ -127,14 +149,14 @@ export class FilesController { Middlewares.allowCrossDomain, BodyParser.raw({type: '*/*', limit: '20mb'}), Middlewares.handleParseHeaders, - this.createHandler() + FilesController.createHandler() ); router.delete('/files/:filename', Middlewares.allowCrossDomain, Middlewares.handleParseHeaders, Middlewares.enforceMasterKeyAccess, - this.deleteHandler() + FilesController.deleteHandler() ); return router; diff --git a/src/Controllers/HooksFileCache.js b/src/Controllers/HooksFileCache.js new file mode 100644 index 0000000000..06aca1322f --- /dev/null +++ b/src/Controllers/HooksFileCache.js @@ -0,0 +1,73 @@ +import { JSONStorageProvider } from './JSONStorageController'; + +export class HooksFileCache { + constructor(appId) { + this.appId = appId; + this.fileName = "hooks-"+this.appId+".json"; + } + + addHook(hook) { + var json = this.getHooks(); + if (hook.triggerName) { + json.triggers[hook.triggerName+"_"+hook.className] = hook; + } else { + json.functions[hook.functionName] = hook; + } + this.saveHooks(json); + } + + saveHooks(json) { + JSONStorageProvider.getAdapter().write(this.fileName, json, this.appId); + } + + getHooks() { + var json = JSONStorageProvider.getAdapter().read(this.fileName, this.appId); + json.triggers = json.triggers || {}; + json.functions = json.functions || {}; + return json; + } + + getFunction(functionName) { + return this.getHooks().functions[functionName]; + + } + + getTrigger(className, triggerName) { + var triggersMap = this.getHooks().triggers; + return triggersMap[`${triggerName}_${className}`]; + } + + getTriggers() { + var triggersMap = this.getHooks().triggers; + return Object.keys(triggersMap).map(function(key){ + return triggersMap[key]; + }); + } + + getFunctions() { + var functions = this.getHooks().functions; + return Object.keys(functions).map(function(key){ + return functions[key]; + }); + } + + removeHook(functionName, triggerName = null) { + var hooks = this.getHooks(); + var changed = false; + if (!triggerName) { + if (hooks.functions[functionName]) { + delete hooks.functions[functionName]; + changed = true; + } + } else { + if (hooks.triggers[triggerName+"_"+functionName]) { + delete hooks.triggers[triggerName+"_"+functionName]; + changed = true; + } + } + if (changed) { + this.saveHooks(hooks) + } + return changed; + } +} diff --git a/src/Controllers/JSONStorageController.js b/src/Controllers/JSONStorageController.js new file mode 100644 index 0000000000..39df8e8436 --- /dev/null +++ b/src/Controllers/JSONStorageController.js @@ -0,0 +1,49 @@ +const path = require("path"), + fs = require("fs"); + +export class JSONStorageController { + + constructor(basePath = null) { + this.basePath = basePath; + } + + getDirectoryForAppId(appId) { + var dir = this.basePath+"/"+appId; + dir = path.resolve(dir); + try { + fs.statSync(this.basePath); + } catch(e) { + fs.mkdir(this.basePath); + } + try { + fs.statSync(dir); + } catch(e) { + fs.mkdir(dir); + } + return dir; + } + + read(file, appId) { + var dir = this.getDirectoryForAppId(appId); + var json = {}; + try { + json = require(dir+"/"+file); + } catch (e) {} + return json; + } + + write(file, data, appId) { + var dir = this.getDirectoryForAppId(appId); + // Write sync to prevent concurrent writes on the same file + fs.writeFileSync(dir+"/"+file, JSON.stringify(data)); + } +} + +export class JSONStorageProvider { + static setAdapter(controller) { + JSONStorageProvider.adapter = controller; + } + static getAdapter() { + return JSONStorageProvider.adapter; + } +} diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 9f9252dcc8..e87e6f764c 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -31,11 +31,11 @@ export class PushController { } }); } - - getExpressRouter() { + + static getExpressRouter() { var router = new PromiseRouter(); router.route('POST','/push', (req) => { - return this.handlePOST(req); + return req.config.pushController.handlePOST(req); }); return router; } diff --git a/src/RestWrite.js b/src/RestWrite.js index 54f5cfc996..5640546780 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -112,7 +112,7 @@ RestWrite.prototype.runBeforeTrigger = function() { return Promise.resolve().then(() => { return triggers.maybeRunTrigger( - 'beforeSave', this.auth, inflatedObject, originalObject); + 'beforeSave', this.auth, inflatedObject, originalObject, this.config.applicationId); }).then((response) => { if (response && response.object) { this.data = response.object; @@ -260,7 +260,7 @@ RestWrite.prototype.handleOAuthAuthData = function(provider) { if (!validateAuthData || !validateAppId) { return false; }; - + return validateAuthData(authData, oauthOptions) .then(() => { if (appIds && typeof validateAppId === "function") { @@ -749,7 +749,7 @@ RestWrite.prototype.runAfterTrigger = function() { originalObject = triggers.inflate(extraData, this.originalData); } - triggers.maybeRunTrigger('afterSave', this.auth, inflatedObject, originalObject); + triggers.maybeRunTrigger('afterSave', this.auth, inflatedObject, originalObject, this.config.applicationId); }; // A helper to figure out what location this operation happens at. diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js new file mode 100644 index 0000000000..587526b42d --- /dev/null +++ b/src/cloud-code/Parse.Cloud.js @@ -0,0 +1,178 @@ +var Parse = require("parse/node"); +var ParseCloudExpress = require("parse-cloud-express"); +Parse.Hooks = require("./Parse.Hooks"); +var triggers = require("../triggers"); + +// The In memory ParseCloud + +function getClassName(parseClass) { + if (parseClass && parseClass.className) { + return parseClass.className; + } + return parseClass; +} + +var ParseCloud = {}; +ParseCloud.define = function(functionName, handler, validationHandler) { + triggers.addFunction(functionName, handler, validationHandler, Parse.applicationId); +}; + +ParseCloud.beforeSave = function(parseClass, handler) { + var className = getClassName(parseClass); + triggers.addTrigger('beforeSave', className, handler, Parse.applicationId); +}; + +ParseCloud.beforeDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + triggers.addTrigger('beforeDelete', className, handler, Parse.applicationId); +}; + +ParseCloud.afterSave = function(parseClass, handler) { + var className = getClassName(parseClass); + triggers.addTrigger('afterSave', className, handler, Parse.applicationId); +}; + +ParseCloud.afterDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + triggers.addTrigger('afterDelete', className, handler, Parse.applicationId); +}; + +Parse.Cloud._removeHook = function(category, name, type, applicationId) { + applicationId = applicationId || Parse.applicationId; + triggers._unregister(applicationId, category, name, type); +}; + +// Store the original Parse Cloud instance +// to prevent multiple wrapping +const PARSE_CLOUD_OVERRIDES = ["define", "beforeSave", "afterSave", "beforeDelete", "afterDelete"]; + +const PARSE_CLOUD_FUNCTIONS = PARSE_CLOUD_OVERRIDES.reduce(function(a, b){ + a[b] = ParseCloudExpress.Parse.Cloud[b]; + return a; +}, {}); + +var hooksCreationStrategy = { + 'never': 'never', // never create hooks, has to manually + 'always': 'always', // try to always update the hooks (POST then PUT) + 'try': 'try', // try to create a hook, but don't override if exists +}; + +function buildURL(name, trigger, config) { + trigger = trigger || "function"; + var URL = config.mountPath+"/"+trigger+"_"+name; + return URL; +} + +function registerHook(type, name, trigger, cloudServerURL, creationStrategy, config) { + + var url = ""; + var hookURL; + var data = {}; + + if (type === "function") { + url = "/hooks/functions"; + data.functionName = name; + hookURL = buildURL(name, "function", config); + + } else if (type == "trigger") { + url = "/hooks/triggers"; + data.className = name; + data.triggerName = trigger; + hookURL = buildURL(name, trigger, config); + } + + // No creation strategy, do nothing + if (!creationStrategy || creationStrategy == hooksCreationStrategy.never) { + return Parse.Promise.as(); + } + + data.url = cloudServerURL + hookURL; + + Parse.initialize(config.applicationId, config.javascriptKey, config.masterKey); + Parse.serverURL = config.serverURL; + return Parse.Hooks.create(data).fail(function(err){ + if (creationStrategy == hooksCreationStrategy.always) { + Parse.initialize(config.applicationId, config.javascriptKey, config.masterKey); + Parse.serverURL = config.serverURL; + return Parse.Hooks.update(data); + } + // Ignore the error then + return Parse.Promise.as(err); + }); +} + +function wrapHandler(handler, config) { + return (request, response) => { + const _success = response.success; + + response.success = function(args) { + var responseValue = args; + if (request.object) { + // If the response was set with the update + // As the original API + request.object.set(args); + responseValue = {object: request.object.toJSON()}; + } + _success(responseValue); + } + + Parse.initialize(config.applicationId, config.javascriptKey, config.masterKey); + Parse.serverURL = config.serverURL; + return handler(request, response); + }; +}; + +var CONFIGURATIONS = {}; + +Parse.Cloud.hooksCreationStrategy = hooksCreationStrategy; + +PARSE_CLOUD_OVERRIDES.map(function(triggerName){ + Object.defineProperty(Parse.Cloud, triggerName, { + get() { + return function(name, handler, creationStrategy) { + const config = CONFIGURATIONS[Parse.applicationId]; + if (!config) { + ParseCloud[triggerName](name, handler, creationStrategy); + return Parse.Promise.as(); + } + const cloudServerURL = Parse.Cloud.serverURL; + + config.mountPath = config.mountPath || "/_hooks"; + creationStrategy = creationStrategy || config.hooksCreationStrategy; + var promise; + if (triggerName === "define") { + promise = registerHook("function", name, null, cloudServerURL, creationStrategy, config); + } else { + name = getClassName(name); + promise = registerHook("trigger", name, triggerName, cloudServerURL, creationStrategy, config); + } + + Parse.initialize(config.applicationId, config.javascriptKey, config.masterKey); + Parse.serverURL = config.serverURL; + handler = wrapHandler(handler, config); + PARSE_CLOUD_FUNCTIONS[triggerName](name, handler); + return promise; + } + } + }); +}); + +Object.defineProperty(Parse.Cloud, "serverURL", { + get() { + const config = CONFIGURATIONS[Parse.applicationId]; + if (config) { + return config.cloudServerURL || `http://localhost:${config.port}`; + } + return; + } +}) + +Parse.Cloud.registerConfiguration = function(config) { + CONFIGURATIONS[config.applicationId] = config; +} + +Parse.Cloud.unregisterApplicationId = function(applicationId) { + delete CONFIGURATIONS[applicationId]; +} + +module.exports = Parse.Cloud; diff --git a/src/cloud-code/Parse.Hooks.js b/src/cloud-code/Parse.Hooks.js new file mode 100644 index 0000000000..4bb8d33c37 --- /dev/null +++ b/src/cloud-code/Parse.Hooks.js @@ -0,0 +1,132 @@ +var request = require("request"); +const send = function(method, path, body) { + + var Parse = require("parse/node").Parse; + + var options = { + method: method, + url: Parse.serverURL + path, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json' + }, + }; + + if (body) { + if (typeof body == "object") { + options.body = JSON.stringify(body); + } else { + options.body = body; + } + } + + var promise = new Parse.Promise(); + request(options, function(err, response, body){ + if (err) { + promise.reject(err); + return; + } + body = JSON.parse(body); + if (body.error) { + promise.reject(body); + } else { + promise.resolve(body); + } + }); + return promise; +} + +var Hooks = {}; + +Hooks.getFunctions = function() { + return Hooks.get("functions"); +} + +Hooks.getTriggers = function() { + return Hooks.get("triggers"); +} + +Hooks.getFunction = function(name) { + return Hooks.get("functions", name); +} + +Hooks.getTrigger = function(className, triggerName) { + return Hooks.get("triggers", className, triggerName); +} + +Hooks.get = function(type, functionName, triggerName) { + var url = "/hooks/"+type; + if(functionName) { + url += "/"+functionName; + if (triggerName) { + url += "/"+triggerName; + } + } + return send("GET", url); +} + +Hooks.createFunction = function(functionName, url) { + return Hooks.create({functionName: functionName, url: url}); +} + +Hooks.createTrigger = function(className, triggerName, url) { + return Hooks.create({className: className, triggerName: triggerName, url: url}); +} + +Hooks.create = function(hook) { + var url; + if (hook.functionName && hook.url) { + url = "/hooks/functions"; + } else if (hook.className && hook.triggerName && hook.url) { + url = "/hooks/triggers"; + } else { + return Promise.reject({error: 'invalid hook declaration', code: 143}); + } + return send("POST", url, hook); +} + +Hooks.updateFunction = function(functionName, url) { + return Hooks.update({functionName: functionName, url: url}); +} + +Hooks.updateTrigger = function(className, triggerName, url) { + return Hooks.update({className: className, triggerName: triggerName, url: url}); +} + + +Hooks.update = function(hook) { + var url; + if (hook.functionName && hook.url) { + url = "/hooks/functions/"+hook.functionName; + delete hook.functionName; + } else if (hook.className && hook.triggerName && hook.url) { + url = "/hooks/triggers/"+hook.className+"/"+hook.triggerName; + delete hook.className; + delete hook.triggerName; + } + return send("PUT", url, hook); +} + +Hooks.deleteFunction = function(functionName) { + return Hooks.delete({functionName: functionName}); +} + +Hooks.deleteTrigger = function(className, triggerName) { + return Hooks.delete({className: className, triggerName: triggerName}); +} + +Hooks.delete = function(hook) { + var url; + if (hook.functionName) { + url = "/hooks/functions/"+hook.functionName; + delete hook.functionName; + } else if (hook.className && hook.triggerName) { + url = "/hooks/triggers/"+hook.className+"/"+hook.triggerName; + delete hook.className; + delete hook.triggerName; + } + return send("PUT", url, '{ "__op": "Delete" }'); +} + +module.exports = Hooks diff --git a/src/cloud-code/README.md b/src/cloud-code/README.md new file mode 100644 index 0000000000..59accb7356 --- /dev/null +++ b/src/cloud-code/README.md @@ -0,0 +1,23 @@ +# Standalone Cloud Code + +to create a new CloudCode server: + +``` +var CloudCodeServer = require("parse-server/lib/cloud-code"); + +var config = { + applicationId: "", + javascriptKey: "", + masterKey: "", + port: 12345, + main: "path/to/main.js", + serverURL: Parse.serverURL, // or the server URL of your parse server + hooksCreationStrategy: "always" | "try" | "never" +}; +var server = new CloudCodeServer(config); + +// From there the cloud code server started on port 12345; +server.app; // the express app running the server +server.stop() // stops the server from listening + +``` diff --git a/src/cloud-code/index.js b/src/cloud-code/index.js new file mode 100644 index 0000000000..1fc3c396e7 --- /dev/null +++ b/src/cloud-code/index.js @@ -0,0 +1,48 @@ +/*jshint node:true */ +var ParseCloudExpressApp = require('parse-cloud-express').app; +var express = require("express"); +var bodyParser = require('body-parser'); + +var CloudCodeServer = function(config) { + 'use strict'; + + var Parse = require("parse/node"); + + config.cloudServerURL = config.cloudServerURL || `http://localhost:${config.port}`; + config.mountPath = config.mountPath || "/_hooks"; + + global.Parse = require("parse/node"); + + // Mount Parse.Cloud + require("./Parse.Cloud"); + // Register the current configuration + Parse.Cloud.registerConfiguration(config); + + // Setup the Parse app + Parse.applicationId = config.applicationId; + Parse.javascriptKey = config.javascriptKey; + Parse.masterKey = config.masterKey; + + const cloudCodeHooksApp = express(); + cloudCodeHooksApp.use(bodyParser.json({ 'type': '*/*' })); + this.httpServer = cloudCodeHooksApp.listen(config.port); + + + cloudCodeHooksApp.use(config.mountPath, ParseCloudExpressApp); + + this.app = cloudCodeHooksApp; + require(config.main); + + if (process.env.NODE_ENV !== "test") { + console.log("[%s] Running Cloud Code for "+Parse.applicationId+" on http://localhost:%s", process.pid, config.port); + } +} +CloudCodeServer.prototype.close = function() { + this.httpServer.close(); +} + +if (require.main === module) { + new CloudCodeServer(JSON.parse(process.argv[2])); +} + +module.exports = CloudCodeServer; diff --git a/src/cloud-code/launcher.js b/src/cloud-code/launcher.js new file mode 100644 index 0000000000..72ab7e590f --- /dev/null +++ b/src/cloud-code/launcher.js @@ -0,0 +1,29 @@ +// ignore code coverage here as it's just a process spanner +// and it gets in the way of the coverage +// And even if it fires, it doesn't get triggered as +// it is a subprocess + +/* istanbul ignore next */ +module.exports = function(options) { + var forever = require('forever-monitor'); + + var foreverOptions = Object.assign({ + max: 9999, + silent: false + }, options.forever) + + foreverOptions.env = process.env; + foreverOptions.args = [JSON.stringify(options)]; + + var cloudCode = new (forever.Monitor)(__dirname + '/index.js', foreverOptions); + + // Kill subprocess on kill + process.on('exit', () => { + cloudCode.stop(); + // Force killin! + cloudCode.child.kill('SIGHUP'); + }); + + cloudCode.start(); + return cloudCode; +} \ No newline at end of file diff --git a/src/cloud/main-2.js b/src/cloud/main-2.js new file mode 100644 index 0000000000..57386853ac --- /dev/null +++ b/src/cloud/main-2.js @@ -0,0 +1,16 @@ +Parse.Cloud.define('hello', function(req, res) { + res.success('Hello world'); +}); + +Parse.Cloud.beforeSave("InjectAppId", (req, res) => { + req.object.set('applicationId', Parse.applicationId); + req.object.set('javascriptKey', Parse.javascriptKey); + req.object.set('masterKey', Parse.masterKey); + res.success(); +}); + +Parse.Cloud.define("echoParseKeys", (req, res) => { + res.success({ applicationId: Parse.applicationId, + javascriptKey: Parse.javascriptKey, + masterKey: Parse.masterKey }); +}); diff --git a/src/cloud/main.js b/src/cloud/main.js index fec259910a..9e53e6376a 100644 --- a/src/cloud/main.js +++ b/src/cloud/main.js @@ -1,5 +1,3 @@ -var Parse = require('parse/node').Parse; - Parse.Cloud.define('hello', function(req, res) { res.success('Hello world!'); }); diff --git a/src/functions.js b/src/functions.js index 8e88aa0358..26b8525786 100644 --- a/src/functions.js +++ b/src/functions.js @@ -3,22 +3,27 @@ var express = require('express'), Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); + rest = require('./rest'), + triggers = require("./triggers"); var router = new PromiseRouter(); function handleCloudFunction(req) { - if (Parse.Cloud.Functions[req.params.functionName]) { + var applicationId = req.config.applicationId; + var theFunction = triggers.getFunction(req.params.functionName, applicationId); + var theValidator = triggers.getValidator(req.params.functionName, applicationId); + if (theFunction) { + const params = Object.assign({}, req.body, req.query); var request = { - params: Object.assign({}, req.body, req.query), + params: params, master: req.auth && req.auth.isMaster, user: req.auth && req.auth.user, installationId: req.info.installationId }; - if (Parse.Cloud.Validators[req.params.functionName]) { - var result = Parse.Cloud.Validators[req.params.functionName](request); + if (theValidator && typeof theValidator === "function") { + var result = theValidator(request); if (!result) { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Validation failed.'); } @@ -26,7 +31,11 @@ function handleCloudFunction(req) { return new Promise(function (resolve, reject) { var response = createResponseObject(resolve, reject); - Parse.Cloud.Functions[req.params.functionName](request, response); + // Force the keys before the function calls. + Parse.applicationId = req.config.applicationId; + Parse.javascriptKey = req.config.javascriptKey; + Parse.masterKey = req.config.masterKey; + theFunction(request, response); }); } else { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid function.'); diff --git a/src/hooks.js b/src/hooks.js new file mode 100644 index 0000000000..2638592c49 --- /dev/null +++ b/src/hooks.js @@ -0,0 +1,205 @@ +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + triggers = require('./triggers'); + +import { HooksFileCache } from './Controllers/HooksFileCache'; + +var wrap = function(hook) { + return function(request, response) { + var jsonBody = {}; + for(var i in request) { + jsonBody[i] = request[i]; + } + if (request.object) { + jsonBody.object = request.object.toJSON(); + jsonBody.object.className = request.object.className; + } + if (request.original) { + jsonBody.original = request.original.toJSON(); + jsonBody.original.className = request.original.className; + } + var jsonRequest = {}; + jsonRequest.headers = { + 'Content-Type': 'application/json' + } + jsonRequest.body = JSON.stringify(jsonBody); + + require("request").post(hook.url, jsonRequest, function(err, res, body){ + var result; + if (body) { + if (typeof body == "string") { + try { + body = JSON.parse(body); + } catch(e) { + err = {error: "Malformed response", code: -1}; + } + } + if (!err) { + result = body.success; + err = body.error; + } + } + if (err) { + return response.error(err); + } else { + return response.success(result); + } + }); + } +} + +var registerHook = function(hook, applicationId) { + var wrappedFunction = wrap(hook); + wrappedFunction.url = hook.url; + if (hook.className) { + triggers.addTrigger(hook.triggerName, hook.className, wrappedFunction, applicationId) + } else { + triggers.addFunction(hook.functionName, wrappedFunction, null, applicationId); + } + new HooksFileCache(applicationId).addHook(hook); +} + +var load = function(applicationId) { + var json = new HooksFileCache(applicationId).getHooks(); + for(var i in json.triggers) { + registerHook(json.triggers[i], applicationId); + } + for(var i in json.functions) { + registerHook(json.functions[i], applicationId); + } +}; + +var createOrUpdateHook = function(aHook, applicationId) { + if (!applicationId) { + throw "Application ID is missing"; + } + var hook; + if (aHook && aHook.functionName && aHook.url) { + hook = {}; + hook.functionName = aHook.functionName; + hook.url = aHook.url; + } else if (aHook && aHook.className && aHook.url && aHook.triggerName && triggers.Types[aHook.triggerName]) { + hook = {}; + hook.className = aHook.className; + hook.url = aHook.url; + hook.triggerName = aHook.triggerName; + } + var promise; + if (!hook) { + promise = Promise.resolve({response: {code: 143, error: "invalid hook declaration"}}); + } else { + registerHook(aHook, applicationId); + promise = Promise.resolve({response: hook}); + } + return promise; +}; + +var createHook = function(aHook, applicationId) { + var hookCache = new HooksFileCache(applicationId); + if (aHook.functionName && hookCache.getFunction(aHook.functionName)) { + return Promise.resolve({response: {code: 143, error: `function name: ${aHook.functionName} already exits`}}); + } + if (aHook.className && aHook.triggerName && hookCache.getTrigger(aHook.className, aHook.triggerName)) { + return Promise.resolve({response: {code: 143, error: `class ${aHook.className} already has trigger ${aHook.triggerName}`}}); + } + return createOrUpdateHook(aHook, applicationId); +}; + +var updateHook = function(aHook, applicationId) { + var hookCache = new HooksFileCache(applicationId); + if (aHook.functionName && !hookCache.getFunction(aHook.functionName)) { + return Promise.resolve({response: {code: 143, error: `no function named: ${aHook.functionName} is defined`}}); + } + if (aHook.className && aHook.triggerName && !hookCache.getTrigger(aHook.className, aHook.triggerName)) { + return Promise.resolve({response: {code: 143, error: `class ${aHook.className} does not exist`}}); + } + return createOrUpdateHook(aHook, applicationId); +} + +var handlePost = function(req) { + return createHook(req.body, req.config.applicationId); +}; + +var handleGetFunctions = function(req) { + var hookCache = new HooksFileCache(req.config.applicationId); + if (req.params.functionName) { + var foundFunction = hookCache.getFunction(req.params.functionName); + if (foundFunction) { + return Promise.resolve({response: foundFunction}); + } else { + return Promise.resolve({response: {error: `no function named: ${req.params.functionName} is defined`, code: 143}}); + } + } + return Promise.resolve({ response: hookCache.getFunctions() }) +} +var handleGetTriggers = function(req) { + var hookCache = new HooksFileCache(req.config.applicationId); + if (req.params.className && req.params.triggerName) { + var foundTrigger = hookCache.getTrigger(req.params.className, req.params.triggerName); + if (foundTrigger) { + return Promise.resolve({response: foundTrigger}); + } else { + return Promise.resolve({response: {error: `class ${req.params.className} does not exist`, code: 143}}); + } + } + + return Promise.resolve({ response: hookCache.getTriggers() }) +} +var handleDelete = function(req) { + var cache = new HooksFileCache(req.config.applicationId); + if (req.params.functionName) { + triggers.removeFunction(req.params.functionName, req.config.applicationId); + cache.removeHook(req.params.functionName, req.params.triggerName) + } else if (req.params.className && req.params.triggerName) { + triggers.removeTrigger(req.params.triggerName, req.params.className,req.config.applicationId); + cache.removeHook(req.params.className, req.params.triggerName) + } + return Promise.resolve({response: {}}); +} + +var handleUpdate = function(req) { + var hook; + if (req.params.functionName && req.body.url) { + hook = {} + hook.functionName = req.params.functionName; + hook.url = req.body.url; + } else if (req.params.className && req.params.triggerName && req.body.url) { + hook = {} + hook.className = req.params.className; + hook.triggerName = req.params.triggerName; + hook.url = req.body.url + } + return updateHook(hook, req.config.applicationId); +} + +var handlePut = function(req) { + var body = req.body; + if (body.__op == "Delete") { + return handleDelete(req); + } else { + return handleUpdate(req); + } +} + +var requireMaster = function(handler) { + return (req) => { + if (req.auth.isMaster) { + return handler(req); + } + return Promise.resolve({response: {error: 'unauthorized'}, status: 403}); + } +} + +var router = new PromiseRouter(); + +router.route('GET', '/hooks/functions', requireMaster(handleGetFunctions)); +router.route('GET', '/hooks/triggers', requireMaster(handleGetTriggers)); +router.route('GET', '/hooks/functions/:functionName', requireMaster(handleGetFunctions)); +router.route('GET', '/hooks/triggers/:className/:triggerName', requireMaster(handleGetTriggers)); +router.route('POST', '/hooks/functions', requireMaster(handlePost)); +router.route('POST', '/hooks/triggers', requireMaster(handlePost)); +router.route('PUT', '/hooks/functions/:functionName', requireMaster(handlePut)); +router.route('PUT', '/hooks/triggers/:className/:triggerName', requireMaster(handlePut)); + +module.exports = router; +module.exports.load = load; diff --git a/src/httpRequest.js b/src/httpRequest.js deleted file mode 100644 index db696c65ee..0000000000 --- a/src/httpRequest.js +++ /dev/null @@ -1,43 +0,0 @@ -var request = require("request"), - Parse = require('parse/node').Parse; - -module.exports = function(options) { - var promise = new Parse.Promise(); - var callbacks = { - success: options.success, - error: options.error - }; - delete options.success; - delete options.error; - if (options.uri && !options.url) { - options.uri = options.url; - delete options.url; - } - if (typeof options.body === 'object') { - options.body = JSON.stringify(options.body); - } - request(options, (error, response, body) => { - var httpResponse = {}; - httpResponse.status = response.statusCode; - httpResponse.headers = response.headers; - httpResponse.buffer = new Buffer(response.body); - httpResponse.cookies = response.headers["set-cookie"]; - httpResponse.text = response.body; - try { - httpResponse.data = JSON.parse(response.body); - } catch (e) {} - // Consider <200 && >= 400 as errors - if (error || httpResponse.status <200 || httpResponse.status >=400) { - if (callbacks.error) { - return callbacks.error(httpResponse); - } - return promise.reject(httpResponse); - } else { - if (callbacks.success) { - return callbacks.success(httpResponse); - } - return promise.resolve(httpResponse); - } - }); - return promise; -}; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 47b639f423..02b59c0dfd 100644 --- a/src/index.js +++ b/src/index.js @@ -9,11 +9,15 @@ var batch = require('./batch'), multer = require('multer'), Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), - httpRequest = require('./httpRequest'); + triggers = require('./triggers'), + hooks = require('./hooks'), + path = require("path"), + CloudCodeLauncher = require("./cloud-code/launcher"); import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; import { FilesController } from './Controllers/FilesController'; +import { JSONStorageProvider, JSONStorageController } from './Controllers/JSONStorageController'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; import { PushController } from './Controllers/PushController'; @@ -27,9 +31,9 @@ import { RolesRouter } from './Routers/RolesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { LoggerController } from './Controllers/LoggerController'; -// Mutate the Parse object to add the Cloud Code handlers -addParseCloud(); - +// Load Parse and mutate Parse.Cloud +global.Parse = Parse; +require("./cloud-code/Parse.Cloud"); // ParseServer works like a constructor of an express app. // The args that we understand are: // "databaseAdapter": a class like ExportAdapter providing create, find, @@ -55,17 +59,102 @@ addParseCloud(); // "push": optional key from configure push function ParseServer(args) { + + loadConfiguration(args); + + // This app serves the Parse API directly. + // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. + var api = express(); + + // File handling needs to be before default middlewares are applied + api.use(FilesController.getExpressRouter()); + + // TODO: separate this from the regular ParseServer object + if (process.env.TESTING == 1) { + console.log('enabling integration testing-routes'); + api.use('/', require('./testing-routes').router); + } + + api.use(bodyParser.json({ 'type': '*/*' })); + api.use(middlewares.allowCrossDomain); + api.use(middlewares.allowMethodOverride); + api.use(middlewares.handleParseHeaders); + + let routers = [ + new ClassesRouter().getExpressRouter(), + new UsersRouter().getExpressRouter(), + new SessionsRouter().getExpressRouter(), + new RolesRouter().getExpressRouter(), + require('./analytics'), + new InstallationsRouter().getExpressRouter(), + require('./functions'), + require('./schemas'), + require('./hooks'), + PushController.getExpressRouter() + ]; + + if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { + routers.push(require('./global_config')); + } + + let appRouter = new PromiseRouter(); + routers.forEach((router) => { + appRouter.merge(router); + }); + batch.mountOnto(appRouter); + + appRouter.mountOnto(api); + + api.use(middlewares.handleParseErrors); + + return api; +} + +function loadConfiguration(args) { + + if (args.applications) { + var port = parseInt(process.env.PORT) || 8080; + port++; + args.applications.forEach(function(app){ + if (typeof app.cloud === "string") { + app.cloud = { + main: path.resolve(app.cloud), + // Increment the port for the sub processes + port: port++, + hooksCreationStrategy: "always" + } + } + if (app.cloud) { + // Setup the defaults if needed for light cloud configurations + app.cloud.applicationId = app.cloud.applicationId || app.appId; + app.cloud.javascriptKey = app.cloud.javascriptKey || app.javascriptKey; + app.cloud.masterKey = app.cloud.masterKey || app.masterKey; + app.cloud.serverURL = app.cloud.serverURL || app.serverURL; + } + + // Global configuration + app.databaseAdapter = app.databaseAdapter || args.databaseAdapter; + app.filesAdapter = app.filesAdapter || args.filesAdapter; + app.jsonCacheDir = app.jsonCacheDir || args.jsonCacheDir; + loadConfiguration(app); + }); + return; + } + if (!args.appId || !args.masterKey) { throw 'You must provide an appId and masterKey!'; } - + Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); + Parse.serverURL = args.serverURL; + if (args.databaseAdapter) { DatabaseAdapter.setAdapter(args.databaseAdapter); } // Make files adapter let filesAdapter = args.filesAdapter || new GridStoreAdapter(); - + let filesController = new FilesController(filesAdapter); + // Make push adapter let pushConfig = args.push; let pushAdapter; @@ -74,6 +163,8 @@ function ParseServer(args) { } else if (pushConfig) { pushAdapter = new ParsePushAdapter(pushConfig) } + + let pushController = new PushController(pushAdapter); // Make logger adapter let loggerAdapter = args.loggerAdapter || new FileLoggerAdapter(); @@ -81,20 +172,23 @@ function ParseServer(args) { if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } + + JSONStorageProvider.setAdapter(new JSONStorageController(args.jsonCacheDir || "./.cache")); + if (args.cloud) { - addParseCloud(); - if (typeof args.cloud === 'function') { + if (typeof args.cloud === 'object') { + // Register configuration for cloud code + Parse.Cloud.registerConfiguration(args.cloud); + CloudCodeLauncher(args.cloud); + } else if (typeof args.cloud === 'function') { args.cloud(Parse) } else if (typeof args.cloud === 'string') { require(args.cloud); } else { - throw "argument 'cloud' must either be a string or a function"; + throw "argument 'cloud' must either be a string or a function or an object"; } - } - let filesController = new FilesController(filesAdapter); - cache.apps[args.appId] = { masterKey: args.masterKey, collectionPrefix: args.collectionPrefix || '', @@ -105,6 +199,7 @@ function ParseServer(args) { fileKey: args.fileKey || 'invalid-file-key', facebookAppIds: args.facebookAppIds || [], filesController: filesController, + pushController: pushController, enableAnonymousUsers: args.enableAnonymousUsers || true, oauth: args.oauth || {}, }; @@ -114,98 +209,7 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } - // Initialize the node client SDK automatically - Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); - if(args.serverURL) { - Parse.serverURL = args.serverURL; - } - - // This app serves the Parse API directly. - // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. - var api = express(); - - // File handling needs to be before default middlewares are applied - api.use('/', filesController.getExpressRouter()); - - // TODO: separate this from the regular ParseServer object - if (process.env.TESTING == 1) { - console.log('enabling integration testing-routes'); - api.use('/', require('./testing-routes').router); - } - - api.use(bodyParser.json({ 'type': '*/*' })); - api.use(middlewares.allowCrossDomain); - api.use(middlewares.allowMethodOverride); - api.use(middlewares.handleParseHeaders); - - let routers = [ - new ClassesRouter().getExpressRouter(), - new UsersRouter().getExpressRouter(), - new SessionsRouter().getExpressRouter(), - new RolesRouter().getExpressRouter(), - require('./analytics'), - new InstallationsRouter().getExpressRouter(), - require('./functions'), - require('./schemas'), - new PushController(pushAdapter).getExpressRouter(), - new LoggerController(loggerAdapter).getExpressRouter() - ]; - if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - routers.push(require('./global_config')); - } - - let appRouter = new PromiseRouter(); - routers.forEach((router) => { - appRouter.merge(router); - }); - batch.mountOnto(appRouter); - - appRouter.mountOnto(api); - - api.use(middlewares.handleParseErrors); - - return api; -} - -function addParseCloud() { - Parse.Cloud.Functions = {}; - Parse.Cloud.Validators = {}; - Parse.Cloud.Triggers = { - beforeSave: {}, - beforeDelete: {}, - afterSave: {}, - afterDelete: {} - }; - - 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); - Parse.Cloud.Triggers.beforeSave[className] = handler; - }; - Parse.Cloud.beforeDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.beforeDelete[className] = handler; - }; - Parse.Cloud.afterSave = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.afterSave[className] = handler; - }; - Parse.Cloud.afterDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.afterDelete[className] = handler; - }; - Parse.Cloud.httpRequest = httpRequest; - global.Parse = Parse; -} - -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return parseClass.className; - } - return parseClass; + require("./hooks").load(args.appId); } module.exports = { diff --git a/src/middlewares.js b/src/middlewares.js index 7dcf8889a5..e7fd6deadb 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -23,7 +23,7 @@ function handleParseHeaders(req, res, next) { clientKey: req.get('X-Parse-Client-Key'), javascriptKey: req.get('X-Parse-Javascript-Key'), dotNetKey: req.get('X-Parse-Windows-Key'), - restAPIKey: req.get('X-Parse-REST-API-Key') + restAPIKey: req.get('X-Parse-REST-API-Key'), }; if (req.body && req.body._noBody) { @@ -89,7 +89,6 @@ function handleParseHeaders(req, res, next) { req.info = info; var isMaster = (info.masterKey === req.config.masterKey); - if (isMaster) { req.auth = new auth.Auth(req.config, true); next(); diff --git a/src/rest.js b/src/rest.js index 552fa6be8c..8c46485e31 100644 --- a/src/rest.js +++ b/src/rest.js @@ -39,8 +39,8 @@ function del(config, auth, className, objectId) { var inflatedObject; return Promise.resolve().then(() => { - if (triggers.getTrigger(className, 'beforeDelete') || - triggers.getTrigger(className, 'afterDelete') || + if (triggers.getTrigger(className, 'beforeDelete', config.applicationId) || + triggers.getTrigger(className, 'afterDelete', config.applicationId) || className == '_Session') { return find(config, auth, className, {objectId: objectId}) .then((response) => { @@ -49,7 +49,7 @@ function del(config, auth, className, objectId) { cache.clearUser(response.results[0].sessionToken); inflatedObject = Parse.Object.fromJSON(response.results[0]); return triggers.maybeRunTrigger('beforeDelete', - auth, inflatedObject); + auth, inflatedObject, null, config.applicationId); } throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for delete.'); @@ -69,7 +69,7 @@ function del(config, auth, className, objectId) { objectId: objectId }, options); }).then(() => { - triggers.maybeRunTrigger('afterDelete', auth, inflatedObject); + triggers.maybeRunTrigger('afterDelete', auth, inflatedObject, null, config.applicationId); return Promise.resolve(); }); } @@ -89,8 +89,8 @@ function update(config, auth, className, objectId, restObject) { enforceRoleSecurity('update', className, auth); return Promise.resolve().then(() => { - if (triggers.getTrigger(className, 'beforeSave') || - triggers.getTrigger(className, 'afterSave')) { + if (triggers.getTrigger(className, 'beforeSave', config.applicationId) || + triggers.getTrigger(className, 'afterSave', config.applicationId)) { return find(config, auth, className, {objectId: objectId}); } return Promise.resolve({}); diff --git a/src/triggers.js b/src/triggers.js index fadb03f085..ea497c0752 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,6 +1,6 @@ // triggers.js - -var Parse = require('parse/node').Parse; +var Parse = require('parse/node').Parse, + cache = require('./cache'); var Types = { beforeSave: 'beforeSave', @@ -9,15 +9,80 @@ var Types = { afterDelete: 'afterDelete' }; -var getTrigger = function(className, triggerType) { - if (Parse.Cloud.Triggers - && Parse.Cloud.Triggers[triggerType] - && Parse.Cloud.Triggers[triggerType][className]) { - return Parse.Cloud.Triggers[triggerType][className]; +var BaseStore = function() { + this.Functions = {} + this.Validators = {} + this.Triggers = Object.keys(Types).reduce(function(base, key){ + base[key] = {}; + return base; + }, {}); +} + +var _triggerStore = {}; + +function addFunction(functionName, handler, validationHandler, applicationId) { + applicationId = applicationId || Parse.applicationId; + _triggerStore[applicationId] = _triggerStore[applicationId] || new BaseStore(); + _triggerStore[applicationId].Functions[functionName] = handler; + _triggerStore[applicationId].Validators[functionName] = validationHandler; +} + +function addTrigger(type, className, handler, applicationId) { + applicationId = applicationId || Parse.applicationId; + _triggerStore[applicationId] = _triggerStore[applicationId] || new BaseStore(); + _triggerStore[applicationId].Triggers[type][className] = handler; +} + +function removeFunction(functionName, applicationId) { + applicationId = applicationId || Parse.applicationId; + delete _triggerStore[applicationId].Functions[functionName] +} + +function removeTrigger(type, className, applicationId) { + applicationId = applicationId || Parse.applicationId; + delete _triggerStore[applicationId].Triggers[type][className] +} + +function _unregister(a,b,c,d) { + if (d) { + removeTrigger(c,d,a); + delete _triggerStore[a][b][c][d]; + } else { + delete _triggerStore[a][b][c]; + } +} + + +var getTrigger = function(className, triggerType, applicationId) { + if (!applicationId) { + throw "Missing ApplicationID"; + } + var manager = _triggerStore[applicationId] + if (manager + && manager.Triggers + && manager.Triggers[triggerType] + && manager.Triggers[triggerType][className]) { + return manager.Triggers[triggerType][className]; } return undefined; }; +var getFunction = function(functionName, applicationId) { + var manager = _triggerStore[applicationId]; + if (manager && manager.Functions) { + return manager.Functions[functionName]; + }; + return undefined; +} + +var getValidator = function(functionName, applicationId) { + var manager = _triggerStore[applicationId]; + if (manager && manager.Validators) { + return manager.Validators[functionName]; + }; + return undefined; +} + var getRequestObject = function(triggerType, auth, parseObject, originalParseObject) { var request = { triggerName: triggerType, @@ -49,7 +114,11 @@ var getRequestObject = function(triggerType, auth, parseObject, originalParseObj // Any changes made to the object in a beforeSave will be included. var getResponseObject = function(request, resolve, reject) { return { - success: function() { + success: function(response) { + // Use the JSON response + if (response && request.triggerName === Types.beforeSave) { + return resolve(response); + } var response = {}; if (request.triggerName === Types.beforeSave) { response['object'] = request.object.toJSON(); @@ -68,15 +137,19 @@ var getResponseObject = function(request, resolve, reject) { // Resolves to an object, empty or containing an object key. A beforeSave // trigger will set the object key to the rest format object to save. // originalParseObject is optional, we only need that for befote/afterSave functions -var maybeRunTrigger = function(triggerType, auth, parseObject, originalParseObject) { +var maybeRunTrigger = function(triggerType, auth, parseObject, originalParseObject, applicationId) { if (!parseObject) { return Promise.resolve({}); } return new Promise(function (resolve, reject) { - var trigger = getTrigger(parseObject.className, triggerType); - if (!trigger) return resolve({}); + var trigger = getTrigger(parseObject.className, triggerType, applicationId); + if (!trigger) return resolve(); var request = getRequestObject(triggerType, auth, parseObject, originalParseObject); var response = getResponseObject(request, resolve, reject); + // Force the current Parse app before the trigger + Parse.applicationId = applicationId; + Parse.javascriptKey = cache.apps[applicationId].javascriptKey || ''; + Parse.masterKey = cache.apps[applicationId].masterKey; trigger(request, response); }); }; @@ -91,10 +164,19 @@ function inflate(data, restObject) { return Parse.Object.fromJSON(copy); } -module.exports = { - getTrigger: getTrigger, - getRequestObject: getRequestObject, - inflate: inflate, - maybeRunTrigger: maybeRunTrigger, - Types: Types -}; +var TriggerManager = {}; + +TriggerManager.getTrigger = getTrigger; +TriggerManager.getRequestObject = getRequestObject; +TriggerManager.inflate = inflate; +TriggerManager.maybeRunTrigger = maybeRunTrigger; +TriggerManager.Types = Types; +TriggerManager.addFunction = addFunction; +TriggerManager.getFunction = getFunction; +TriggerManager.removeTrigger = removeTrigger; +TriggerManager.removeFunction = removeFunction; +TriggerManager.getValidator = getValidator; +TriggerManager.addTrigger = addTrigger; +TriggerManager._unregister = _unregister; + +module.exports = TriggerManager;