Skip to content

Commit

Permalink
Direct Access to parse-server (#2316)
Browse files Browse the repository at this point in the history
* Adds ParseServerRESTController experimental support

* Adds basic tests

* Do not create sessionToken when requests come from cloudCode #1495
  • Loading branch information
flovilmart authored Sep 9, 2016
1 parent ccf2b14 commit cb7b549
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 56 deletions.
119 changes: 119 additions & 0 deletions spec/ParseServerRESTController.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const ParseServerRESTController = require('../src/ParseServerRESTController').ParseServerRESTController;
const ParseServer = require('../src/ParseServer').default;
let RESTController;

describe('ParseServerRESTController', () => {

beforeEach(() => {
RESTController = ParseServerRESTController(Parse.applicationId, ParseServer.promiseRouter({appId: Parse.applicationId}));
})

it('should handle a get request', (done) => {
RESTController.request("GET", "/classes/MyObject").then((res) => {
expect(res.results.length).toBe(0);
done();
}, (err) => {
console.log(err);
jfail(err);
done();
});
});

it('should handle a get request with full serverURL mount path', (done) => {
RESTController.request("GET", "/1/classes/MyObject").then((res) => {
expect(res.results.length).toBe(0);
done();
}, (err) => {
jfail(err);
done();
});
});

it('should handle a POST batch', (done) => {
RESTController.request("POST", "batch", {
requests: [
{
method: 'GET',
path: '/classes/MyObject'
},
{
method: 'POST',
path: '/classes/MyObject',
body: {"key": "value"}
},
{
method: 'GET',
path: '/classes/MyObject'
}
]
}).then((res) => {
expect(res.length).toBe(3);
done();
}, (err) => {
jfail(err);
done();
});
});

it('should handle a POST request', (done) => {
RESTController.request("POST", "/classes/MyObject", {"key": "value"}).then((res) => {
return RESTController.request("GET", "/classes/MyObject");
}).then((res) => {
expect(res.results.length).toBe(1);
expect(res.results[0].key).toEqual("value");
done();
}).fail((err) => {
console.log(err);
jfail(err);
done();
});
});

it('ensures sessionTokens are properly handled', (done) => {
let userId;
Parse.User.signUp('user', 'pass').then((user) => {
userId = user.id;
let sessionToken = user.getSessionToken();
return RESTController.request("GET", "/users/me", undefined, {sessionToken});
}).then((res) => {
// Result is in JSON format
expect(res.objectId).toEqual(userId);
done();
}).fail((err) => {
console.log(err);
jfail(err);
done();
});
});

it('ensures masterKey is properly handled', (done) => {
let userId;
Parse.User.signUp('user', 'pass').then((user) => {
userId = user.id;
let sessionToken = user.getSessionToken();
return Parse.User.logOut().then(() => {
return RESTController.request("GET", "/classes/_User", undefined, {useMasterKey: true});
});
}).then((res) => {
expect(res.results.length).toBe(1);
expect(res.results[0].objectId).toEqual(userId);
done();
}, (err) => {
jfail(err);
done();
});
});

it('ensures no session token is created on creating users', (done) => {
RESTController.request("POST", "/classes/_User", {username: "hello", password: "world"}).then(() => {
let query = new Parse.Query('_Session');
return query.find({useMasterKey: true});
}).then(sessions => {
expect(sessions.length).toBe(0);
done();
}, (err) => {
jfail(err);
done();
});
});
});
43 changes: 26 additions & 17 deletions src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import DatabaseController from './Controllers/DatabaseController';
import SchemaCache from './Controllers/SchemaCache';
import ParsePushAdapter from 'parse-server-push-adapter';
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';

import { ParseServerRESTController } from './ParseServerRESTController';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();

Expand Down Expand Up @@ -273,6 +275,29 @@ class ParseServer {
api.use(bodyParser.json({ 'type': '*/*' , limit: maxUploadSize }));
api.use(middlewares.allowMethodOverride);

let appRouter = ParseServer.promiseRouter({ appId });
api.use(appRouter.expressRouter());

api.use(middlewares.handleParseErrors);

//This causes tests to spew some useless warnings, so disable in test
if (!process.env.TESTING) {
process.on('uncaughtException', (err) => {
if ( err.code === "EADDRINUSE" ) { // user-friendly message for this common error
console.error(`Unable to listen on port ${err.port}. The port is already in use.`);
process.exit(0);
} else {
throw err;
}
});
}
if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1') {
Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter));
}
return api;
}

static promiseRouter({appId}) {
let routers = [
new ClassesRouter(),
new UsersRouter(),
Expand Down Expand Up @@ -301,23 +326,7 @@ class ParseServer {
appRouter.use(middlewares.handleParseHeaders);

batch.mountOnto(appRouter);

api.use(appRouter.expressRouter());

api.use(middlewares.handleParseErrors);

//This causes tests to spew some useless warnings, so disable in test
if (!process.env.TESTING) {
process.on('uncaughtException', (err) => {
if ( err.code === "EADDRINUSE" ) { // user-friendly message for this common error
console.error(`Unable to listen on port ${err.port}. The port is already in use.`);
process.exit(0);
} else {
throw err;
}
});
}
return api;
return appRouter;
}

static createLiveQueryServer(httpServer, config) {
Expand Down
99 changes: 99 additions & 0 deletions src/ParseServerRESTController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const Config = require('./Config');
const Auth = require('./Auth');
const RESTController = require('parse/lib/node/RESTController');
const URL = require('url');
const Parse = require('parse/node');

function getSessionToken(options) {
if (options && typeof options.sessionToken === 'string') {
return Parse.Promise.as(options.sessionToken);
}
return Parse.Promise.as(null);
}

function getAuth(options, config) {
if (options.useMasterKey) {
return Parse.Promise.as(new Auth.Auth({config, isMaster: true, installationId: 'cloud' }));
}
return getSessionToken(options).then((sessionToken) => {
if (sessionToken) {
options.sessionToken = sessionToken;
return Auth.getAuthForSessionToken({
config,
sessionToken: sessionToken,
installationId: 'cloud'
});
} else {
return Parse.Promise.as(new Auth.Auth({ config, installationId: 'cloud' }));
}
})
}

function ParseServerRESTController(applicationId, router) {
function handleRequest(method, path, data = {}, options = {}) {
// Store the arguments, for later use if internal fails
let args = arguments;

let config = new Config(applicationId);
let serverURL = URL.parse(config.serverURL);
if (path.indexOf(serverURL.path) === 0) {
path = path.slice(serverURL.path.length, path.length);
}

if (path[0] !== "/") {
path = "/" + path;
}

if (path === '/batch') {
let promises = data.requests.map((request) => {
return handleRequest(request.method, request.path, request.body, options).then((response) => {
return Parse.Promise.as({success: response});
}, (error) => {
return Parse.Promise.as({error: {code: error.code, error: error.message}});
});
});
return Parse.Promise.all(promises);
}

let query;
if (method === 'GET') {
query = data;
}

return new Parse.Promise((resolve, reject) => {
getAuth(options, config).then((auth) => {
let request = {
body: data,
config,
auth,
info: {
applicationId: applicationId,
sessionToken: options.sessionToken
},
query
};
return Promise.resolve().then(() => {
return router.tryRouteRequest(method, path, request);
}).then((response) => {
resolve(response.response, response.status, response);
}, (err) => {
if (err instanceof Parse.Error &&
err.code == Parse.Error.INVALID_JSON &&
err.message == `cannot route ${method} ${path}`) {
RESTController.request.apply(null, args).then(resolve, reject);
} else {
reject(err);
}
});
}, reject);
});
};

return {
request: handleRequest,
ajax: RESTController.ajax
};
};

export default ParseServerRESTController;
export { ParseServerRESTController };
63 changes: 39 additions & 24 deletions src/PromiseRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ import express from 'express';
import url from 'url';
import log from './logger';
import {inspect} from 'util';
const Layer = require('express/lib/router/layer');

function validateParameter(key, value) {
if (key == 'className') {
if (value.match(/_?[A-Za-z][A-Za-z_0-9]*/)) {
return value;
}
} else if (key == 'objectId') {
if (value.match(/[A-Za-z0-9]+/)) {
return value;
}
} else {
return value;
}
}


export default class PromiseRouter {
// Each entry should be an object with:
Expand Down Expand Up @@ -70,7 +86,8 @@ export default class PromiseRouter {
this.routes.push({
path: path,
method: method,
handler: handler
handler: handler,
layer: new Layer(path, null, handler)
});
};

Expand All @@ -83,30 +100,15 @@ export default class PromiseRouter {
if (route.method != method) {
continue;
}
// NOTE: we can only route the specific wildcards :className and
// :objectId, and in that order.
// This is pretty hacky but I don't want to rebuild the entire
// express route matcher. Maybe there's a way to reuse its logic.
var pattern = '^' + route.path + '$';

pattern = pattern.replace(':className',
'(_?[A-Za-z][A-Za-z_0-9]*)');
pattern = pattern.replace(':objectId',
'([A-Za-z0-9]+)');
var re = new RegExp(pattern);
var m = path.match(re);
if (!m) {
continue;
}
var params = {};
if (m[1]) {
params.className = m[1];
}
if (m[2]) {
params.objectId = m[2];
let layer = route.layer || new Layer(route.path, null, route.handler);
let match = layer.match(path);
if (match) {
let params = layer.params;
Object.keys(params).forEach((key) => {
params[key] = validateParameter(key, params[key]);
});
return {params: params, handler: route.handler};
}

return {params: params, handler: route.handler};
}
};

Expand All @@ -124,6 +126,19 @@ export default class PromiseRouter {
expressRouter() {
return this.mountOnto(express.Router());
}

tryRouteRequest(method, path, request) {
var match = this.match(method, path);
if (!match) {
throw new Parse.Error(
Parse.Error.INVALID_JSON,
'cannot route ' + method + ' ' + path);
}
request.params = match.params;
return new Promise((resolve, reject) => {
match.handler(request).then(resolve, reject);
});
}
}

// A helper function to make an express handler out of a a promise
Expand Down
5 changes: 5 additions & 0 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,11 @@ RestWrite.prototype.createSessionTokenIfNeeded = function() {
}

RestWrite.prototype.createSessionToken = function() {
// cloud installationId from Cloud Code,
// never create session tokens from there.
if (this.auth.installationId && this.auth.installationId === 'cloud') {
return;
}
var token = 'r:' + cryptoUtils.newToken();

var expiresAt = this.config.generateSessionExpiresAt();
Expand Down
Loading

2 comments on commit cb7b549

@zingano
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flovilmart - how do I try this out? Presumably it's not enabled for everyone in 2.2.19?

@flovilmart
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zingano you can enable by setting PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS=1 in your environment before starting Parse-server

Please sign in to comment.