This commit is contained in:
Ritchie
2012-10-23 11:23:40 -07:00
19 changed files with 289 additions and 113 deletions

View File

@@ -31,13 +31,6 @@ realtime resource server
Consult the [documentation](http://deployd.github.com/deployd) or contact `ritchie at deployd com`.
## changelog
### 0.5
- removed `property.optional` in favor of `property.required`
- changed `object._id` to `object.id` on all stored objects
## license
Copyright 2012 deployd, llc

View File

@@ -4,9 +4,16 @@
- Added new data editor
- Fixed major bug where calling error() would not always cancel the request
- Fixed bug where PUT would fail without an error if you provided a query
- Changed root behavior - no longer ignores cancel() in events
- Fixed bugs preventing events from being `emit()`ed to users in certain connection states
- Fixed bug where boolean query values (?bool=true) were not treated as booleans
- Fixed unnecessary error when parsing JSON body
- Added more intelegent body parsing
## 0.6.6
- Added CORS support
- Exposed the server object to modules as `process.server`
- Fixed a rare bug where the first request after a login would not be authenticated
- Fixed minor bug when loading only node modules

View File

@@ -33,7 +33,7 @@ program
.option('-d, --dashboard', 'start the dashboard immediately')
.option('-o, --open', 'open in a browser')
.option('-e, --environment [env]', 'defaults to development')
.option('-h, --host [host]', 'specify host for mongo server')
.option('-H, --host [host]', 'specify host for mongo server')
.option('-P, --mongoPort [mongoPort]', 'mongodb port to connect to')
.option('-n, --dbname [dbname]', 'name of the mongo database')
.option('-a, --auth', 'prompts for mongo server credentials')

View File

@@ -1,4 +1,4 @@
# About Deployd Server
# About Deployd
We call Deployd a **resource server**. A resource server is not a library, but a complete server that works out of the box, and can be customized to fit the needs of your app by adding resources. Resources are ready-made components that live at a URL and provide functionality to your client app.

View File

@@ -26,7 +26,7 @@ To provide additional collaborators access to push new versions and access the d
You can also deploy your app on your server, or on a cloud hosting service such as EC2 or Heroku. The server must support [Node.js](http://nodejs.org/).
Deployd also requires a [MongoDB] (http://www.mongodb.org/) database, which can be hosted on the same server or externally.
Deployd also requires a [MongoDB](http://www.mongodb.org/) database, which can be hosted on the same server or externally.
If you have root shell access on the deployment server, you can install Deployd on it using the command `npm install -g deployd`.
Otherwise, you will need to install Deployd as a dependency of your app itself using `npm install deployd` in the root directory of your app.

View File

@@ -6,8 +6,8 @@ The `internal-client` module is responsible for building a server-side version o
## internalClient.build(server, [session], [stack])
var internalClient = require('deployd/lib/internalClient');
var dpd = internalClient.build(server, req.session, req.stack);
var internalClient = require('deployd/lib/internal-client');
var dpd = internalClient.build(server);
dpd.todos.get(function(data, err) {
// Do something...

View File

@@ -61,7 +61,7 @@ If a callback is provided the script will be run in **async mode**. The callback
* fn(err, script)
Load a new `script` at the given file `path`. Callback with an error if one occured or a new `Script` loaded from the contents of the file.
Load a new `script` at the given file `path`. Callback with an error if one occured or a new `Script` loaded from the contents of the file.
## Default Domain
@@ -71,8 +71,6 @@ Scripts are executed with a default sandbox and set of domain functions. These a
Throws an error that immediately stops the execution of a context and calls the callback passed to `script.run()` passing the error as the first argument.
`cancel()` does not have an effect if the current `Context.isRoot` or `Context.internal` is true.
### emit([collection], [query], event, data)
Stability: will change in 0.7
@@ -85,5 +83,7 @@ The default sandbox or global object in a `Script` comes with several other prop
- `me` - the current user if one exists on the `Context`
- `this` - an empty object if not overridden by the `domain`
- `internal` - a boolean property, true if this request has been initiated by another script
- `isRoot` - a boolean property, true if this request is authenticated as root (from the dashboard or a custom script)
- `query` - the current `Context`'s query
- `console` - support for `console.log()` and other `console` methods

View File

@@ -161,6 +161,29 @@ Dpd.js will prevent recursive queries. This works by returning `null` from a `dp
]
}
### internal
Equal to true if this request has been sent by another script.
// Example: On GET /posts
// Posts with a parent are invisible, but are counted by their parent
if (this.parentId && !internal) cancel();
dpd.posts.get({parentId: this.id}, function(posts) {
this.childPosts = posts.length;
});
### isRoot
Equal to true if this request has been authenticated as root (has the `dpd-ssh-key` header with the appropriate key)
// Example: On PUT /users
// Protect reputation property - should only be calculated by a custom script.
if (!isRoot) protect('reputation');
### console.log()
console.log([arguments]...)

View File

@@ -213,6 +213,11 @@
return true;
};
vm.edit.isEditableModal = function() {
var type = vm.selectedProp().type;
return vm.edit.isJson() || type === 'string';
};
vm.edit.hasChanged = ko.observable(false);
vm.edit.editValue.subscribe(function(newVal) {
vm.edit.hasChanged(true);
@@ -418,7 +423,7 @@
rowVm._deleteRow = function() {
if (rowVm.id()) {
var index = vm.data.indexOf(rowVm);
dpd(resource).del(rowVm.id(), function(res, err) {
dpd(resource).del(rowVm.id(), {$skipEvents: true}, function(res, err) {
if (err) return showError("Error deleting row", err);
vm.data.remove(rowVm);
var data = vm.data();
@@ -462,15 +467,23 @@
rowVm[name](value);
if (rowVm.id()) {
if (!options.dontNotify) {
createUndo("Changed " + vm.selectedProp().name, function() {
rowVm._saveProp(prop, lastValue, {dontNotify: true});
vm.selectedProp(prop);
vm.selectedRow(rowVm);
});
}
body[name] = value;
dpd(resource).put(rowVm.id(), body);
body.$skipEvents = true;
dpd(resource).put(rowVm.id(), body, function(res, err) {
if (err) {
showError("Error updating row", err);
rowVm[name](lastValue);
}
if (!options.dontNotify) {
createUndo("Changed " + vm.selectedProp().name, function() {
rowVm._saveProp(prop, lastValue, {dontNotify: true});
vm.selectedProp(prop);
vm.selectedRow(rowVm);
});
}
});
} else if (!options.dontSave) {
rowVm._save();
}
@@ -516,6 +529,7 @@
}
function postRow(data, fn) {
data.$skipEvents = true;
dpd(resource).post(data, function(res, err) {
if (err) return fn(null, err);
vm.fadeInRows.push(getRowById(res.id)); //In case it's already there
@@ -647,7 +661,7 @@
return false;
case 13: //enter
if (e.ctrlKey) {
if (e.ctrlKey && vm.edit.isEditableModal()) {
vm.selectedRow()._editProp(vm.selectedProp(), {modal: true});
} else {
vm.selectedRow()._editProp(vm.selectedProp());
@@ -793,7 +807,7 @@
var begin = page - 1;
begin = Math.max(0, Math.min(begin, getLastPage() - 1));
dpd(resource).get({$skip: begin*PAGE_SIZE, $limit: PAGE_SIZE*3}, function(res, err) {
dpd(resource).get({$skip: begin*PAGE_SIZE, $limit: PAGE_SIZE*3, $skipEvents: true}, function(res, err) {
if (err) return;
vm.currentPage(page);
ko.mapping.fromJS({data: res}, rowMapping, vm);

View File

@@ -400,7 +400,11 @@ ko.bindingHandlers.aceEditorOptions = {
ko.bindingHandlers.typeahead = {
update: function(element, valueAccessor) {
var value = ko.utils.unwrapObservable(valueAccessor());
$(element).typeahead({source: value});
if (window.typeahead) {
$(element).typeahead({source: value});
} else {
$(element).typeahead({source: []});
}
}
};

View File

@@ -143,16 +143,19 @@ Collection.prototype.sanitizeQuery = function (query) {
// skip properties that do not exist, but allow $ queries and id
if(!prop && key.indexOf('$') !== 0 && key !== 'id') return;
// hack - $limitRecursion is not a mongo property so we'll get rid of it, too
// hack - $limitRecursion and $skipEvents are not mongo properties so we'll get rid of them, too
if (key === '$limitRecursion') return;
if (key === '$skipEvents') return;
if(expected == 'number' && actual == 'string') {
sanitized[key] = parseFloat(val);
} else if(expected == 'boolean' && actual != 'boolean') {
sanitized[key] = (val === 'true') ? true : false;
} else if (typeof val !== 'undefined') {
sanitized[key] = val;
}
});
return sanitized;
};
@@ -316,9 +319,7 @@ Collection.prototype.find = function (ctx, fn) {
errors[key] = val || true;
},
hide: function(property) {
if (!session.isRoot) {
delete data[property];
}
delete data[property];
},
'this': data,
data: data
@@ -350,9 +351,7 @@ Collection.prototype.find = function (ctx, fn) {
errors[key] = val || true;
},
hide: function(property) {
if (!session.isRoot) {
delete data[property];
}
delete data[property];
},
'this': data,
data: data
@@ -381,17 +380,18 @@ Collection.prototype.remove = function (ctx, fn) {
, store = this.store
, session = ctx.session
, query = ctx.query
, sanitizedQuery = this.sanitizeQuery(query)
, errors;
if(!(query && query.id)) return fn('You must include a query with an id when deleting an object from a collection.');
store.find(query, function (err, result) {
store.find(sanitizedQuery, function (err, result) {
if(err) {
return fn(err);
}
function done(err) {
if(err) return fn(err);
store.remove(query, fn);
store.remove(sanitizedQuery, fn);
if(session.emitToAll) session.emitToAll(collection.name + ':changed');
}
@@ -424,6 +424,7 @@ Collection.prototype.save = function (ctx, fn) {
, store = this.store
, session = ctx.session
, item = ctx.body
, query = ctx.query || {}
, client = ctx.dpd
, errors;
@@ -453,6 +454,7 @@ Collection.prototype.save = function (ctx, fn) {
function done(err, item) {
errors = errors && {errors: errors};
debug('errors: %j', err);
fn(errors || err, item);
}
@@ -463,24 +465,21 @@ Collection.prototype.save = function (ctx, fn) {
errors[key] = val || true;
},
hide: function(property) {
if (!session.isRoot) {
delete item[property];
}
delete item[property];
},
protect: function(property) {
if (!session.isRoot) {
delete item[property];
}
delete item[property];
},
'this': item,
data: item
};
function put() {
var id = query.id;
store.first(query, function(err, obj) {
var id = query.id
, sanitizedQuery = collection.sanitizeQuery(query);
store.first(sanitizedQuery, function(err, obj) {
if(!obj) {
if (Object.keys(query) === 1) {
if (Object.keys(sanitizedQuery) === 1) {
return done(new Error("No object exists with that id"));
} else {
return done(new Error("No object exists that matches that query"));
@@ -671,8 +670,8 @@ Collection.prototype.execCommands = function (type, obj, commands) {
};
Collection.prototype.shouldRunEvent = function(ev, ctx) {
var runEvents = ctx && ((ctx.body && ctx.body.$runEvents) || (ctx.query && ctx.query.$runEevents))
, rootPrevent = ctx && ctx.session && ctx.session.isRoot && !runEvents;
var skipEvents = ctx && ((ctx.body && ctx.body.$skipEvents) || (ctx.query && ctx.query.$skipEvents))
, rootPrevent = ctx && ctx.session && ctx.session.isRoot && skipEvents;
return !rootPrevent && ev;
};

View File

@@ -37,15 +37,14 @@ Script.prototype.run = function (ctx, domain, fn) {
var scriptContext = {
'this': {},
cancel: function(msg, status) {
if (!req.isRoot) {
var err = {message: msg, statusCode: status};
throw err;
}
var err = {message: msg, statusCode: status};
throw err;
},
me: session && session.user,
console: console,
query: ctx.query,
internal: req && req.internal,
isRoot: req && req.session && req.session.isRoot,
emit: function(collection, query, event, data) {
if(arguments.length === 4) {
session.emitToUsers(collection, query, event, data);

View File

@@ -25,19 +25,20 @@ function SessionStore(namespace, db, sockets) {
var socketQueue = this.socketQueue = new EventEmitter()
, socketIndex = this.socketIndex = {};
// TODO - sockets.on('connection', ...) - map to a session id based on socket.handshake.headers
sockets && sockets.on('connection', function (socket) {
// NOTE: do not use set here ever, the `Cookies` api is meant to get a req, res
// but we are just using it for a cookie parser
var cookies = new Cookies(socket.handshake)
, sid = cookies.get('sid');
if(sockets) {
sockets.on('connection', function (socket) {
// NOTE: do not use set here ever, the `Cookies` api is meant to get a req, res
// but we are just using it for a cookie parser
var cookies = new Cookies(socket.handshake)
, sid = cookies.get('sid');
if(sid) {
// index sockets against their session id
socketIndex[sid] = socket;
socketQueue.emit(sid, socket);
}
});
if(sid) {
// index sockets against their session id
socketIndex[sid] = socket;
socketQueue.emit(sid, socket);
}
});
}
Store.apply(this, arguments);
}
@@ -105,43 +106,52 @@ SessionStore.prototype.createSession = function(sid, fn) {
*/
function Session(data, store, sockets, rawSockets) {
<<<<<<< HEAD
var sess = this;
=======
var sid;
>>>>>>> 929285c8b89118aa505a218b939cec34d431e38e
this.data = data;
if(data && data.id) this.sid = data.id;
if(data && data.id) this.sid = sid = data.id;
this.store = store;
// create faux socket, to queue any events until
// a real socket is available
var socketWrapper = this.socket = {
on: function () {
var s = sockets[sid];
// if we have a real socket, use it
if(this._socket) {
this._socket.apply(this._socket, arguments);
if(s) {
s.on.apply(s, arguments);
} else {
// otherwise add to bind queue
var queue = this._bindQueue = this._bindQueue || [];
queue.push(arguments);
}
},
emit: function () {
emit: function (ev) {
var s = sockets[sid];
// if we have a real socket, use it
if(this._socket) {
this._socket.emit.apply(this._socket, arguments);
if(s) {
s.emit.apply(s, arguments);
} else {
// otherwise add to bind queue
var queue = this._emitQueue = this._bindQueue || [];
queue.push(arguments);
}
},
_socket: sockets[this.sid]
}
};
this.emitToUsers = function(collection, query, event, data) {
collection.get(query, function(users) {
var userSession;
// TODO: arguments in weird order
if(users && users.id) {
<<<<<<< HEAD
userSession = userSessionIndex[err.id];
=======
userSession = userSessionIndex[users.id];
>>>>>>> 929285c8b89118aa505a218b939cec34d431e38e
if(userSession && userSession.socket) {
userSession.socket.emit(event, data);
}
@@ -169,7 +179,6 @@ function Session(data, store, sockets, rawSockets) {
sess.set({host: add + ':' + process.server.options.port}).save();
});
socketWrapper._socket = socket;
// drain bind queue
if(socketWrapper._bindQueue && socketWrapper._bindQueue.length) {
socketWrapper._bindQueue.forEach(function (args) {

View File

@@ -14,33 +14,41 @@ exports.setup = function(req, res, next) {
, handler = corser.create({supportsCredentials: true, methods: ALLOWED_METHODS, origins: origins});
handler(req, res, function () {
if (req.method === "OPTIONS") {
// End CORS preflight request.
res.writeHead(204);
res.end();
} else {
var mime = req.headers['content-type'] || '';
mime = mime.split(';')[0]; //Just in case there's multiple mime types, pick the first
req.cookies = res.cookies = new Cookies(req, res);
if(~req.url.indexOf('?')) {
try {
req.query = parseQuery(req.url);
} catch (ex) {
res.setHeader('Content-Type', 'text/plain');
res.statusCode = 400;
res.end('Failed to parse querystring: ' + ex);
return;
}
}
switch(req.method) {
case 'OPTIONS':
// End CORS preflight request.
res.writeHead(204);
res.end();
break;
case 'POST':
case 'PUT':
case 'DELETE':
var mime = req.headers['content-type'] || 'application/json';
mime = mime.split(';')[0]; //Just in case there's multiple mime types, pick the first
req.cookies = res.cookies = new Cookies(req, res);
if(~req.url.indexOf('?')) {
try {
req.query = parseQuery(req.url);
} catch (ex) {
res.setHeader('Content-Type', 'text/plain');
res.statusCode = 400;
res.end('Failed to parse querystring: ' + ex);
return;
if(autoParse[mime]) {
autoParse[mime](req, res, mime, next);
} else {
if(req.headers['content-length']) req.pause();
next();
}
}
if(autoParse[mime]) {
autoParse[mime](req, res, mime, next);
} else {
if(req.headers['content-length']) req.pause();
break;
default:
next();
}
break;
}
});
};
@@ -68,7 +76,18 @@ var parseBody = exports.parseBody = function(req, res, mime, callback) {
}
try {
req.body = parser.parse(buf);
if(buf.length) {
if(mime === 'application/json' && '{' != buf[0] && '[' != buf[0]) {
res.setHeader('Content-Type', 'text/plain');
res.statusCode = 400;
res.end('Could not parse invalid JSON');
return;
}
req.body = parser.parse(buf);
} else {
req.body = {};
}
callback();
} catch (ex) {
res.setHeader('Content-Type', 'text/plain');

View File

@@ -5,6 +5,7 @@
<title>Mocha Tests</title>
<link rel="stylesheet" href="mocha.css" />
<script src="/dpd.js"></script>
<script src="jquery.js"></script>
<script src="chai.js"></script>
<script src="mocha.js"></script>
<script src="util.js"></script>

View File

@@ -1,3 +1,4 @@
/*global _dpd:false */
describe('Collection', function() {
describe('dpd.todos', function() {
it('should exist', function() {
@@ -67,6 +68,18 @@ describe('Collection', function() {
done();
});
});
it('should create a todo that exists in the store', function(done) {
dpd.todos.post({title: 'faux'}, function (todo, err) {
expect(todo.id.length).to.equal(16);
expect(todo.title).to.equal('faux');
expect(err).to.not.exist;
dpd.todos.get(todo.id, function(res, err) {
if (err) return done(err);
expect(res.title).to.equal('faux');
done();
});
});
});
});
describe('.post({title: "notvalid"}, fn)', function() {
@@ -211,6 +224,29 @@ describe('Collection', function() {
});
});
});
describe('GET /full?boolean=true', function () {
it('should filter boolean properties by query string', function(done) {
dpd.full.post({boolean: true}, function (full) {
dpd.full.post({boolean: false}, function(full){
$.ajax({
type: "GET",
url: "/full?boolean=true",
success: function (res) {
expect(res.length).to.be.greaterThan(0);
res.forEach(function(obj){
expect(obj.boolean).to.equal(true);
});
done();
},
error: function (e) {
done(e);
}
});
});
});
});
});
describe('.get({id: "non existent"}, fn)', function() {
it('should return a 404', function(done) {
@@ -381,7 +417,6 @@ describe('Collection', function() {
todoId = res.id;
dpd.todos.put(todoId, {message: "notvalidput"}, next);
}).chain(function(next, res, err) {
console.log(res, err);
expect(err).to.exist;
expect(err.errors).to.exist;
expect(err.errors.message).to.equal("message should not be notvalidput");
@@ -592,6 +627,77 @@ describe('Collection', function() {
});
});
describe('root', function() {
afterEach(function(done) {
_dpd.ajax.headers = {};
cleanCollection(dpd.todos, done);
});
describe('dpd-ssh-key', function() {
beforeEach(function() {
_dpd.ajax.headers = {
'dpd-ssh-key': true
};
});
it('should detect root', function(done) {
chain(function(next) {
dpd.todos.post({title: 'valid'}, next);
}).chain(function(next, res, err) {
if (err) return done(err);
expect(res.isRoot).to.equal(true);
done();
});
});
it('should allow skipping events', function(done) {
chain(function(next) {
dpd.todos.post({title: 'notvalid', $skipEvents: true}, next);
}).chain(function(next, res, err) {
if (err) return done(err);
expect(res.title).to.equal('notvalid');
done();
});
});
it('should allow skipping events on get', function(done) {
var id;
chain(function(next) {
dpd.todos.post({title: '$GET_CANCEL'}, next);
}).chain(function(next, res, err) {
if (err) return done(err);
id = res.id;
dpd.todos.get(id, {$skipEvents: true}, next);
}).chain(function(next, res, err) {
if (err) return done(err);
expect(res.title).to.equal("$GET_CANCEL");
done();
});
});
});
it('should not allow skipping events', function(done) {
chain(function(next) {
dpd.todos.post({title: 'notvalid', $skipEvents: true}, next);
}).chain(function(next, res, err) {
expect(err).to.exist;
expect(err.errors).to.exist;
done();
});
});
it('should not detect root', function(done) {
chain(function(next) {
dpd.todos.post({title: 'valid'}, next);
}).chain(function(next, res, err) {
if (err) return done(err);
expect(res.isRoot).to.not.exist;
done();
});
});
});
describe('dpd.recursive', function() {
beforeEach(function(done) {
dpd.recursive.post({name: "dataception"}, function(res) {

View File

@@ -35,4 +35,8 @@ if (this.title === "$CANCEL_TEST") {
if (this.title === "$INTERNAL_CANCEL_TEST") {
if (!internal) cancel('internal cancel');
}
if (isRoot) {
this.isRoot = true;
}

View File

@@ -47,19 +47,6 @@ describe('Session', function() {
});
}
it('should make the socket available from the session', function(done) {
var sockets = new EventEmitter()
, store = new SessionStore('sessions', db.create(TEST_DB), sockets);
store.createSession(function (err, session) {
var fauxSocket = {handshake: { headers: {cookie: 'name=value; name2=value2; sid=' + session.sid} } };
sockets.emit('connection', fauxSocket);
expect(session.socket._socket).to.equal(fauxSocket);
done(err);
});
});
it('should make sockets available even before they exist', function(done) {
this.timeout(100);

View File

@@ -83,6 +83,17 @@ describe('.parseBody()', function() {
req.emit('data', value);
req.emit('end');
});
it('should interpret an empty body as an empty object', function(done) {
var req = new Stream();
http.parseBody(req, this.res, 'application/json', function(err) {
expect(err).to.not.exist;
expect(req.body).to.eql({});
done();
});
req.emit('end');
});
});
});