Merge "veyron/tools/build: Print more detailed error information"
diff --git a/examples/mdb/Makefile b/examples/mdb/Makefile
index 88a62b3..d453a6a 100644
--- a/examples/mdb/Makefile
+++ b/examples/mdb/Makefile
@@ -1,7 +1,10 @@
build:
- ${VEYRON_ROOT}/veyron/scripts/build/go install {veyron,veyron2}/...
+ ${VEYRON_ROOT}/veyron/scripts/build/go install veyron/examples/mdb/... veyron/services/mounttable/mounttabled veyron/services/store/stored veyron/tools/identity
run: build
./run.sh
-.PHONY: build run
+test:
+ ./test.sh
+
+.PHONY: build run test
diff --git a/examples/mdb/run.sh b/examples/mdb/run.sh
index a678718..7ead48a 100755
--- a/examples/mdb/run.sh
+++ b/examples/mdb/run.sh
@@ -9,8 +9,8 @@
trap onexit INT TERM EXIT
onexit() {
- exec 2> /dev/null
- kill $(jobs -pr)
+ exec 2>/dev/null
+ kill $(jobs -p)
rm -rf "${ID_FILE}"
}
diff --git a/examples/mdb/test.sh b/examples/mdb/test.sh
new file mode 100755
index 0000000..656560f
--- /dev/null
+++ b/examples/mdb/test.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+
+# Tests the mdb example.
+#
+# Builds binaries, starts up services, waits a few seconds, then checks that the
+# store browser responds with valid data.
+
+set -e
+set -u
+
+readonly THIS_SCRIPT="$0"
+readonly WORK_DIR=$(mktemp -d)
+
+trap onexit INT TERM EXIT
+
+onexit() {
+ exec 2>/dev/null
+ kill $(jobs -p)
+ rm -rf "${WORK_DIR}"
+}
+
+fail() {
+ [[ $# -gt 0 ]] && echo "${THIS_SCRIPT} $*"
+ echo FAIL
+ exit 1
+}
+
+pass() {
+ echo PASS
+ exit 0
+}
+
+main() {
+ make build || fail "line ${LINENO}: failed to build"
+ ./run.sh >/dev/null 2>&1 &
+
+ sleep 5 # Wait for services to warm up.
+
+ URL="http://localhost:5000"
+ FILE="${WORK_DIR}/index.html"
+
+ curl 2>/dev/null "${URL}" -o "${FILE}" || fail "line ${LINENO}: failed to fetch ${URL}"
+
+ if grep -q moviesbox "${FILE}"; then
+ pass
+ else
+ cat ${FILE}
+ fail "line ${LINENO}: fetched page does not meet expectations"
+ fi
+}
+
+main "$@"
diff --git a/examples/pipetobrowser/Makefile b/examples/pipetobrowser/Makefile
index e80138b..0953b87 100644
--- a/examples/pipetobrowser/Makefile
+++ b/examples/pipetobrowser/Makefile
@@ -1,7 +1,6 @@
PATH:=$(VEYRON_ROOT)/environment/cout/node/bin:$(PATH)
PATH:=node_modules/.bin:../node_modules/.bin:$(PATH)
-VEYRON_JS_API=$(VEYRON_ROOT)/veyron/javascript/api
VEYRON_BUILD_SCRIPT=$(VEYRON_ROOT)/veyron/scripts/build/go
# All JS files except build.js and third party
@@ -24,11 +23,8 @@
export
# Build and copies Veyron from local source
-browser/third-party/veyron: $(VEYRON_JS_API)
- mkdir -p browser/third-party
- (cd $(VEYRON_JS_API) && ./vgrunt build)
- mkdir -p browser/third-party/veyron
- cp -rf $(VEYRON_JS_API)/dist/*.* browser/third-party/veyron
+browser/third-party/veyron: node_modules
+ cp -rf $</veyron/dist/ $@
# Install JSPM and Bower packages as listed in browser/package.json from JSPM and browser/bower.json from bower
browser/third-party: browser/package.json browser/bower.json
diff --git a/examples/pipetobrowser/package.json b/examples/pipetobrowser/package.json
index add9381..c38e890 100644
--- a/examples/pipetobrowser/package.json
+++ b/examples/pipetobrowser/package.json
@@ -2,10 +2,13 @@
"name": "pipe-to-browser",
"version": "0.0.1",
"description": "P2B allows one to pipe anything from shell console to the browser. Data being piped to the browser then is displayed in a graphical and formatted way by a 'viewer' Viewers are pluggable pieces of code that know how to handle and display a stream of data.",
+ "dependencies": {
+ "veyron": "git+ssh://git@github.com:veyron/veyron.js.git"
+ },
"devDependencies": {
"jspm": "~0.6.7",
"vulcanize": "~0.3.0",
"serve": "~1.4.0",
"bower": "~1.3.8"
}
-}
\ No newline at end of file
+}
diff --git a/examples/todos/.gitignore b/examples/todos/.gitignore
index d5c005f..25e502e 100644
--- a/examples/todos/.gitignore
+++ b/examples/todos/.gitignore
@@ -1,4 +1,5 @@
node_modules
npm-debug.log
todos_appd/node_modules
-todos_appd/public/js/veyron.*
+todos_appd/public/bundle.*
+todos_appd/third_party/veyron.*
diff --git a/examples/todos/Makefile b/examples/todos/Makefile
index 86f2607..76f0c00 100644
--- a/examples/todos/Makefile
+++ b/examples/todos/Makefile
@@ -1,26 +1,31 @@
-VEYRON_JS_API := ${VEYRON_ROOT}/veyron/javascript/api
+# TODO(sadovsky): Eliminate separate {build,run,watch}app rules once everything
+# is wired together.
export PATH := node_modules/.bin:${PATH}
-build: buildgo buildnode buildbrowser
+VEYRON_JS_API := ${VEYRON_ROOT}/veyron/javascript/api
+BUNDLE_JS := todos_appd/public/bundle.js
+
+node_modules:
+ npm install
+ (cd todos_appd && npm install)
buildgo:
${VEYRON_ROOT}/veyron/scripts/build/go install {veyron,veyron2}/...
-buildnode:
- npm install
+buildapp: node_modules
+ browserify -d todos_appd/browser/*.js -p [minifyify --map bundle.js.map --output ${BUNDLE_JS}.map] -o ${BUNDLE_JS}
-buildbrowser:
- (cd ${VEYRON_JS_API} && ./vgrunt build) && \
- mkdir -p todos_appd/public/js && \
- cp -rf ${VEYRON_JS_API}/dist/veyron.* todos_appd/public/js
+build: buildgo buildapp
run: build
./run.sh
-# TODO(sadovsky): Merge into other rules once everything's wired up.
-runapp:
- (cd todos_appd && npm install && npm start)
+runapp: buildapp
+ (cd todos_appd && npm start)
+
+watchapp:
+ watch -n 1 make buildapp
gofmt:
gofmt -w .
@@ -28,10 +33,10 @@
clean:
rm -rf node_modules
rm -rf todos_appd/node_modules
- rm -rf todos_appd/public/js/veyron.*
+ rm -rf todos_appd/public/bundle.*
+ rm -rf todos_appd/third_party/veyron.*
-lint:
- npm install --dev
- jshint todos_appd/server.js todos_appd/public/js/*.js --exclude "**/veyron.*"
+lint: node_modules
+ jshint todos_appd/server.js todos_appd/browser/*.js
-.PHONY: build buildgo buildnode buildbrowser run runapp gofmt clean lint
+.PHONY: buildgo buildapp build run runapp watchapp gofmt clean lint
diff --git a/examples/todos/package.json b/examples/todos/package.json
index b289006..431c8ce 100644
--- a/examples/todos/package.json
+++ b/examples/todos/package.json
@@ -1,7 +1,12 @@
{
"name": "todos",
"version": "0.0.1",
+ "dependencies": {
+ "veyron": "git+ssh://git@github.com:veyron/veyron.js.git"
+ },
"devDependencies": {
- "jshint": "^2.5.2"
+ "browserify": "^5.9.1",
+ "jshint": "^2.5.2",
+ "minifyify": "^4.0.3"
}
}
diff --git a/examples/todos/todos_appd/browser/collection.js b/examples/todos/todos_appd/browser/collection.js
new file mode 100644
index 0000000..589465a
--- /dev/null
+++ b/examples/todos/todos_appd/browser/collection.js
@@ -0,0 +1,136 @@
+// TODO: Use minimongo?
+
+'use strict';
+
+module.exports = Collection;
+
+var CHANGE = 'change';
+
+function BaseEvent(type) {
+ this.type = type;
+}
+
+function ChangeEvent() {
+ BaseEvent.call(this, CHANGE);
+}
+
+function Collection(name) {
+ this.name_ = name;
+ this.vals_ = [];
+
+ this.listeners_ = {};
+ this.listeners_[CHANGE] = [];
+}
+
+Collection.prototype = {
+ find: function(q, opts) {
+ var that = this;
+ q = this.normalize_(q);
+ var res = _.filter(this.vals_, function(v) {
+ return that.matches_(v, q);
+ });
+ if (opts.sort) {
+ // TODO: Eliminate simplifying assumptions.
+ var keys = _.keys(opts.sort);
+ console.assert(keys.length === 1);
+ var key = keys[0];
+ console.assert(opts.sort[key] === 1);
+ res = res.sort(function(a, b) {
+ // TODO: Verify and enhance comparator.
+ return a[key] > b[key];
+ });
+ }
+ return _.cloneDeep(res);
+ },
+ findOne: function(q, opts) {
+ var all = this.find(q, opts);
+ if (all.length > 0) {
+ return all[0];
+ }
+ return null;
+ },
+ insert: function(v) {
+ console.assert(!_.has(v, '_id'));
+ v = _.assign({}, v, {_id: this.vals_.length});
+ this.vals_.push(v);
+ this.dispatchEvent_(new ChangeEvent());
+ return v._id;
+ },
+ remove: function(q) {
+ var that = this;
+ q = this.normalize_(q);
+ this.vals_ = _.filter(this.vals_, function(v) {
+ return !that.matches_(v, q);
+ });
+ this.dispatchEvent_(new ChangeEvent());
+ },
+ update: function(q, opts) {
+ var that = this;
+ q = this.normalize_(q);
+ var vals = _.filter(this.vals_, function(v) {
+ return that.matches_(v, q);
+ });
+
+ // TODO: Eliminate simplifying assumptions.
+ var keys = _.keys(opts);
+ console.assert(keys.length === 1);
+ var key = keys[0];
+ console.assert(_.contains(['$addToSet', '$pull', '$set'], key));
+ var opt = opts[key];
+ var fields = _.keys(opt);
+ console.assert(keys.length === 1);
+ var field = fields[0];
+
+ _.each(vals, function(val) {
+ switch (key) {
+ case '$addToSet':
+ val[field] = _.union(val[field], [opt[field]]);
+ break;
+ case '$pull':
+ val[field] = _.without(val[field], opt[field]);
+ break;
+ case '$set':
+ val[field] = opt[field];
+ break;
+ }
+ });
+
+ this.dispatchEvent_(new ChangeEvent());
+ },
+ addEventListener: function(type, handler) {
+ this.listeners_[type].push(handler);
+ },
+ removeEventListener: function(type, handler) {
+ this.listeners_[type] = _.without(this.listeners_[type], handler);
+ },
+ on: function(type, handler) {
+ this.addEventListener(type, handler);
+ },
+ normalize_: function(q) {
+ if (_.isObject(q)) {
+ return q;
+ }
+ return {_id: q};
+ },
+ matches_: function(v, q) {
+ var keys = _.keys(q);
+ for (var i = 0; i < keys.length; i++) {
+ var key = keys[i];
+ if (_.isArray(v[key]) && !_.isArray(q[key])) {
+ if (!_.contains(v[key], q[key])) {
+ return false;
+ }
+ } else {
+ if (q[key] !== v[key]) {
+ return false;
+ }
+ }
+ }
+ return true;
+ },
+ dispatchEvent_: function(e) {
+ _.each(this.listeners_[e.type], function(handler) {
+ handler(e);
+ });
+ }
+};
diff --git a/examples/todos/todos_appd/browser/defaults.js b/examples/todos/todos_appd/browser/defaults.js
new file mode 100644
index 0000000..1455103
--- /dev/null
+++ b/examples/todos/todos_appd/browser/defaults.js
@@ -0,0 +1,61 @@
+'use strict';
+
+var Collection = require('./collection');
+
+var lists = new Collection('lists');
+var todos = new Collection('todos');
+
+// Copied from meteor/todos/server/bootstrap.js.
+var data = [
+ {name: 'Meteor Principles',
+ contents: [
+ ['Data on the Wire', 'Simplicity', 'Better UX', 'Fun'],
+ ['One Language', 'Simplicity', 'Fun'],
+ ['Database Everywhere', 'Simplicity'],
+ ['Latency Compensation', 'Better UX'],
+ ['Full Stack Reactivity', 'Better UX', 'Fun'],
+ ['Embrace the Ecosystem', 'Fun'],
+ ['Simplicity Equals Productivity', 'Simplicity', 'Fun']
+ ]
+ },
+ {name: 'Languages',
+ contents: [
+ ['Lisp', 'GC'],
+ ['C', 'Linked'],
+ ['C++', 'Objects', 'Linked'],
+ ['Python', 'GC', 'Objects'],
+ ['Ruby', 'GC', 'Objects'],
+ ['JavaScript', 'GC', 'Objects'],
+ ['Scala', 'GC', 'Objects'],
+ ['Erlang', 'GC'],
+ ['6502 Assembly', 'Linked']
+ ]
+ },
+ {name: 'Favorite Scientists',
+ contents: [
+ ['Ada Lovelace', 'Computer Science'],
+ ['Grace Hopper', 'Computer Science'],
+ ['Marie Curie', 'Physics', 'Chemistry'],
+ ['Carl Friedrich Gauss', 'Math', 'Physics'],
+ ['Nikola Tesla', 'Physics'],
+ ['Claude Shannon', 'Math', 'Computer Science']
+ ]
+ }
+];
+
+var timestamp = (new Date()).getTime();
+for (var i = 0; i < data.length; i++) {
+ var listId = lists.insert({name: data[i].name});
+ for (var j = 0; j < data[i].contents.length; j++) {
+ var info = data[i].contents[j];
+ todos.insert({listId: listId,
+ text: info[0],
+ done: false,
+ timestamp: timestamp,
+ tags: info.slice(1)});
+ timestamp += 1; // ensure unique timestamp
+ }
+}
+
+exports.lists = lists;
+exports.todos = todos;
diff --git a/examples/todos/todos_appd/browser/dispatcher.js b/examples/todos/todos_appd/browser/dispatcher.js
new file mode 100644
index 0000000..f2e3d10
--- /dev/null
+++ b/examples/todos/todos_appd/browser/dispatcher.js
@@ -0,0 +1,43 @@
+// Note, this is a mix of React Actions, Dispatcher, and Stores.
+
+'use strict';
+
+module.exports = Dispatcher;
+
+function Dispatcher(lists, todos) {
+ this.lists_ = lists;
+ this.todos_ = todos;
+}
+
+Dispatcher.prototype = {
+ addList: function(name) {
+ return this.lists_.insert({name: name});
+ },
+ editListName: function(listId, name) {
+ this.lists_.update(listId, {$set: {name: name}});
+ },
+ addTodo: function(listId, text, tags) {
+ return this.todos_.insert({
+ listId: listId,
+ text: text,
+ done: false,
+ timestamp: (new Date()).getTime(),
+ tags: tags
+ });
+ },
+ removeTodo: function(todoId) {
+ this.todos_.remove(todoId);
+ },
+ editTodoText: function(todoId, text) {
+ this.todos_.update(todoId, {$set: {text: text}});
+ },
+ markTodoDone: function(todoId, done) {
+ this.todos_.update(todoId, {$set: {done: done}});
+ },
+ addTag: function(todoId, tag) {
+ this.todos_.update(todoId, {$addToSet: {tags: tag}});
+ },
+ removeTag: function(todoId, tag) {
+ this.todos_.update(todoId, {$pull: {tags: tag}});
+ }
+};
diff --git a/examples/todos/todos_appd/browser/index.js b/examples/todos/todos_appd/browser/index.js
new file mode 100644
index 0000000..75123e8
--- /dev/null
+++ b/examples/todos/todos_appd/browser/index.js
@@ -0,0 +1,448 @@
+'use strict';
+
+var Dispatcher = require('./dispatcher');
+
+////////////////////////////////////////
+// Global state
+
+var defaults = require('./defaults');
+var cLists = defaults.lists;
+var cTodos = defaults.todos;
+
+var d = new Dispatcher(cLists, cTodos);
+
+////////////////////////////////////////
+// Helpers
+
+function activateInput(input) {
+ input.focus();
+ input.select();
+}
+
+function okCancelEvents(callbacks) {
+ var ok = callbacks.ok || function() {};
+ var cancel = callbacks.cancel || function() {};
+ function done(ev) {
+ var value = ev.target.value;
+ if (value) {
+ ok(value, ev);
+ } else {
+ cancel(ev);
+ }
+ }
+ return {
+ onKeyDown: function(ev) {
+ if (ev.which === 27) { // esc
+ cancel(ev);
+ }
+ },
+ onKeyUp: function(ev) {
+ if (ev.which === 13) { // enter
+ done(ev);
+ }
+ },
+ onBlur: function(ev) {
+ done(ev);
+ }
+ };
+}
+
+////////////////////////////////////////
+// Components
+
+var TagFilter = React.createClass({
+ displayName: 'TagFilter',
+ render: function() {
+ var that = this;
+ var tagFilter = this.props.tagFilter;
+ var tagInfos = [], totalCount = 0;
+ _.each(this.props.todos, function(todo) {
+ _.each(todo.tags, function(tag) {
+ var tagInfo = _.find(tagInfos, function(x) {
+ return x.tag === tag;
+ });
+ if (!tagInfo) {
+ tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag});
+ } else {
+ tagInfo.count++;
+ }
+ });
+ totalCount++;
+ });
+ tagInfos = _.sortBy(tagInfos, function(x) { return x.tag; });
+ // Note, the 'All items' tag handling is fairly convoluted in Meteor.
+ tagInfos.unshift({
+ tag: null,
+ count: totalCount,
+ selected: tagFilter === null
+ });
+
+ var children = [];
+ _.each(tagInfos, function(tagInfo) {
+ var count = React.DOM.span(
+ {className: 'count'}, '(' + tagInfo.count + ')');
+ children.push(React.DOM.div({
+ className: 'tag' + (tagInfo.selected ? ' selected' : ''),
+ onMouseDown: function() {
+ var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
+ that.props.setTagFilter(newTagFilter);
+ }
+ }, tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count));
+ });
+ return React.DOM.div(
+ {id: 'tag-filter', className: 'tag-list'},
+ React.DOM.div({className: 'label'}, 'Show:'),
+ children);
+ }
+});
+
+var Tags = React.createClass({
+ displayName: 'Tags',
+ getInitialState: function() {
+ return {
+ addingTag: false
+ };
+ },
+ componentDidUpdate: function() {
+ if (this.state.addingTag) {
+ activateInput(this.getDOMNode().querySelector('#edittag-input'));
+ }
+ },
+ render: function() {
+ var that = this;
+ var children = [];
+ _.each(this.props.tags, function(tag) {
+ // Note, we must specify the "key" prop so that React doesn't reuse the
+ // opacity=0 node after a tag is removed.
+ children.push(React.DOM.div(
+ {className: 'tag removable_tag', key: tag},
+ React.DOM.div({className: 'name'}, tag),
+ React.DOM.div({
+ className: 'remove',
+ onClick: function(ev) {
+ ev.target.parentNode.style.opacity = 0;
+ // Wait for CSS animation to finish.
+ window.setTimeout(function() {
+ d.removeTag(that.props.todoId, tag);
+ }, 300);
+ }
+ })));
+ });
+ if (this.state.addingTag) {
+ children.push(React.DOM.div(
+ {className: 'tag edittag'},
+ React.DOM.input(_.assign({
+ type: 'text',
+ id: 'edittag-input',
+ defaultValue: ''
+ }, okCancelEvents({
+ ok: function(value) {
+ d.addTag(that.props.todoId, value);
+ that.setState({addingTag: false});
+ },
+ cancel: function() {
+ that.setState({addingTag: false});
+ }
+ })))));
+ } else {
+ children.push(React.DOM.div({
+ className: 'tag addtag',
+ onClick: function() {
+ that.setState({addingTag: true});
+ }
+ }, '+tag'));
+ }
+ return React.DOM.div({className: 'item-tags'}, children);
+ }
+});
+
+var Todo = React.createClass({
+ displayName: 'Todo',
+ getInitialState: function() {
+ return {
+ editingText: false
+ };
+ },
+ componentDidUpdate: function() {
+ if (this.state.editingText) {
+ activateInput(this.getDOMNode().querySelector('#todo-input'));
+ }
+ },
+ render: function() {
+ var that = this;
+ var todo = this.props.todo, children = [];
+ if (this.state.editingText) {
+ children.push(React.DOM.div(
+ {className: 'edit'},
+ React.DOM.input(_.assign({
+ id: 'todo-input',
+ type: 'text',
+ defaultValue: todo.text
+ }, okCancelEvents({
+ ok: function(value) {
+ d.editTodoText(todo._id, value);
+ that.setState({editingText: false});
+ },
+ cancel: function() {
+ that.setState({editingText: false});
+ }
+ })))));
+ } else {
+ children.push(React.DOM.div({
+ className: 'destroy',
+ onClick: function() {
+ d.removeTodo(todo._id);
+ }
+ }));
+ children.push(React.DOM.div(
+ {className: 'display'},
+ React.DOM.input({
+ className: 'check',
+ name: 'markdone',
+ type: 'checkbox',
+ checked: todo.done,
+ onClick: function() {
+ d.markTodoDone(!todo.done);
+ }
+ }),
+ React.DOM.div({
+ className: 'todo-text',
+ onDoubleClick: function() {
+ that.setState({editingText: true});
+ }
+ }, todo.text)));
+ }
+ children.push(new Tags({todoId: todo._id, tags: todo.tags}));
+ return React.DOM.li({
+ className: 'todo' + (todo.done ? ' done' : '')
+ }, children);
+ }
+});
+
+var Todos = React.createClass({
+ displayName: 'Todos',
+ render: function() {
+ var that = this;
+ if (this.props.listId === null) {
+ return null;
+ }
+ var children = [];
+ if (this.props.todos === null) {
+ children.push('Loading...');
+ } else {
+ var tagFilter = this.props.tagFilter, items = [];
+ _.each(this.props.todos, function(todo) {
+ if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
+ items.push(new Todo({todo: todo}));
+ }
+ });
+ children.push(React.DOM.div(
+ {id: 'new-todo-box'},
+ React.DOM.input(_.assign({
+ type: 'text',
+ id: 'new-todo',
+ placeholder: 'New item'
+ }, okCancelEvents({
+ ok: function(value, ev) {
+ var tags = tagFilter ? [tagFilter] : [];
+ d.addTodo(that.props.listId, value, tags);
+ ev.target.value = '';
+ }
+ })))));
+ children.push(React.DOM.ul({id: 'item-list'}, items));
+ }
+ return React.DOM.div({id: 'items-view'}, children);
+ }
+});
+
+var List = React.createClass({
+ displayName: 'List',
+ getInitialState: function() {
+ return {
+ editingName: false
+ };
+ },
+ componentDidUpdate: function() {
+ if (this.state.editingName) {
+ activateInput(this.getDOMNode().querySelector('#list-name-input'));
+ }
+ },
+ render: function() {
+ var that = this;
+ var list = this.props.list, child;
+ // http://facebook.github.io/react/docs/forms.html#controlled-components
+ if (this.state.editingName) {
+ child = React.DOM.div(
+ {className: 'edit'},
+ React.DOM.input(_.assign({
+ className: 'list-name-input',
+ id: 'list-name-input',
+ type: 'text',
+ defaultValue: list.name
+ }, okCancelEvents({
+ ok: function(value) {
+ d.editListName(list._id, value);
+ that.setState({editingName: false});
+ },
+ cancel: function() {
+ that.setState({editingName: false});
+ }
+ }))));
+ } else {
+ child = React.DOM.div(
+ {className: 'display'},
+ React.DOM.a({
+ className: 'list-name' + (list.name ? '' : ' empty'),
+ href: '/lists/' + list._id
+ }, list.name));
+ }
+ return React.DOM.div({
+ className: 'list' + (list.selected ? ' selected' : ''),
+ onMouseDown: function() {
+ that.props.setListId(list._id);
+ },
+ onClick: function(ev) {
+ ev.preventDefault(); // prevent page refresh
+ },
+ onDoubleClick: function() {
+ that.setState({editingName: true});
+ }
+ }, child);
+ }
+});
+
+var Lists = React.createClass({
+ displayName: 'Lists',
+ render: function() {
+ var that = this;
+ var children = [React.DOM.h3({}, 'Todo Lists')];
+ if (this.props.lists === null) {
+ children.push(React.DOM.div({id: 'lists'}, 'Loading...'));
+ } else {
+ var lists = [];
+ _.each(this.props.lists, function(list) {
+ list.selected = that.props.listId === list._id;
+ lists.push(new List({
+ list: list,
+ setListId: that.props.setListId
+ }));
+ });
+ children.push(React.DOM.div({id: 'lists'}, lists));
+ children.push(React.DOM.div(
+ {id: 'createList'},
+ React.DOM.input(_.assign({
+ type: 'text',
+ id: 'new-list',
+ placeholder: 'New list'
+ }, okCancelEvents({
+ ok: function(value, ev) {
+ var id = d.addList(value);
+ that.props.setListId(id);
+ ev.target.value = '';
+ }
+ })))));
+ }
+ return React.DOM.div({}, children);
+ }
+});
+
+var Page = React.createClass({
+ displayName: 'Page',
+ getInitialState: function() {
+ return {
+ lists: null, // all lists
+ todos: null, // all todos for current listId
+ listId: this.props.initialListId, // current list
+ tagFilter: null // current tag
+ };
+ },
+ fetchLists: function() {
+ return cLists.find({}, {sort: {name: 1}});
+ },
+ fetchTodos: function(listId) {
+ if (listId === null) {
+ return null;
+ }
+ return cTodos.find({listId: listId}, {sort: {timestamp: 1}});
+ },
+ updateURL: function() {
+ var router = this.props.router, listId = this.state.listId;
+ router.navigate(listId === null ? '' : '/lists/' + String(listId));
+ },
+ componentDidMount: function() {
+ var that = this;
+ var lists = this.fetchLists();
+ var listId = this.state.listId;
+ if (listId === null && lists.length > 0) {
+ listId = lists[0]._id;
+ }
+ this.setState({
+ lists: lists,
+ todos: this.fetchTodos(listId),
+ listId: listId
+ });
+ this.updateURL();
+
+ cLists.on('change', function() {
+ that.setState({lists: that.fetchLists()});
+ });
+ cTodos.on('change', function() {
+ that.setState({todos: that.fetchTodos(that.state.listId)});
+ });
+ },
+ componentDidUpdate: function() {
+ this.updateURL();
+ },
+ render: function() {
+ var that = this;
+ return React.DOM.div({}, [
+ React.DOM.div({id: 'top-tag-filter'}, new TagFilter({
+ todos: this.state.todos,
+ tagFilter: this.state.tagFilter,
+ setTagFilter: function(tagFilter) {
+ that.setState({tagFilter: tagFilter});
+ }
+ })),
+ React.DOM.div({id: 'main-pane'}, new Todos({
+ todos: this.state.todos,
+ listId: this.state.listId,
+ tagFilter: this.state.tagFilter
+ })),
+ React.DOM.div({id: 'side-pane'}, new Lists({
+ lists: this.state.lists,
+ listId: this.state.listId,
+ setListId: function(listId) {
+ if (listId !== that.state.listId) {
+ that.setState({
+ todos: that.fetchTodos(listId),
+ listId: listId
+ });
+ }
+ }
+ }))
+ ]);
+ }
+});
+
+////////////////////////////////////////
+// UI initialization
+
+var Router = Backbone.Router.extend({
+ routes: {
+ '': 'main',
+ 'lists/:listId': 'main'
+ }
+});
+var router = new Router();
+
+var page;
+router.on('route:main', function(listId) {
+ console.assert(!page);
+ if (listId !== null) {
+ listId = parseInt(listId, 10);
+ }
+ page = new Page({router: router, initialListId: listId});
+ React.renderComponent(page, document.getElementById('c'));
+});
+
+Backbone.history.start({pushState: true});
diff --git a/examples/todos/todos_appd/index.html b/examples/todos/todos_appd/index.html
index 4871e63..0edab81 100644
--- a/examples/todos/todos_appd/index.html
+++ b/examples/todos/todos_appd/index.html
@@ -3,20 +3,16 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- <link rel="stylesheet" href="/public/css/index.css">
+ <link rel="stylesheet" href="/public/index.css">
<title>Todos</title>
</head>
<body>
<div id="c"></div>
- <script src="/public/third_party/jquery-2.1.1.min.js"></script>
- <script src="/public/third_party/lodash.min.js"></script>
- <script src="/public/third_party/backbone-min.js"></script>
+ <script src="/third_party/jquery-2.1.1.min.js"></script>
+ <script src="/third_party/lodash.min.js"></script>
+ <script src="/third_party/backbone-min.js"></script>
<!--<script src="public/third_party/react-0.11.1.min.js"></script>-->
- <script src="/public/third_party/react-0.11.1.js"></script>
- <script src="/public/js/collection.js"></script>
- <script src="/public/js/bootstrap.js"></script>
- <script src="/public/js/dispatcher.js"></script>
- <script src="/public/js/index.js"></script>
- <script>app.init();</script>
+ <script src="/third_party/react-0.11.1.js"></script>
+ <script src="/public/bundle.js"></script>
</body>
</html>
diff --git a/examples/todos/todos_appd/misc/readme.txt b/examples/todos/todos_appd/misc/readme.txt
deleted file mode 100644
index 942c2c7..0000000
--- a/examples/todos/todos_appd/misc/readme.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-make lint
-make run
-
-cd ~/dev/meteor/todos
-meteor reset
-meteor run
diff --git a/examples/todos/todos_appd/misc/todo.txt b/examples/todos/todos_appd/misc/todo.txt
deleted file mode 100644
index 4650a84..0000000
--- a/examples/todos/todos_appd/misc/todo.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-- Browserify, use require() syntax
-- Use Veyron store and sync
-- Write alternative, Mercury-based UI
diff --git a/examples/todos/todos_appd/package.json b/examples/todos/todos_appd/package.json
index 4365b1b..4832279 100644
--- a/examples/todos/todos_appd/package.json
+++ b/examples/todos/todos_appd/package.json
@@ -1,10 +1,7 @@
{
- "name": "todos",
+ "name": "todos_appd",
"version": "0.0.1",
"dependencies": {
"express": "^4.6.1"
- },
- "devDependencies": {
- "browserify": "^5.0.8"
}
}
diff --git a/examples/todos/todos_appd/public/static/close_16.png b/examples/todos/todos_appd/public/close_16.png
similarity index 100%
rename from examples/todos/todos_appd/public/static/close_16.png
rename to examples/todos/todos_appd/public/close_16.png
Binary files differ
diff --git a/examples/todos/todos_appd/public/static/destroy.png b/examples/todos/todos_appd/public/destroy.png
similarity index 100%
rename from examples/todos/todos_appd/public/static/destroy.png
rename to examples/todos/todos_appd/public/destroy.png
Binary files differ
diff --git a/examples/todos/todos_appd/public/css/index.css b/examples/todos/todos_appd/public/index.css
similarity index 96%
rename from examples/todos/todos_appd/public/css/index.css
rename to examples/todos/todos_appd/public/index.css
index 0f75a81..6b21348 100644
--- a/examples/todos/todos_appd/public/css/index.css
+++ b/examples/todos/todos_appd/public/index.css
@@ -202,7 +202,7 @@
}
#item-list .todo:hover .destroy {
- background: url("/public/static/destroy.png") no-repeat 0 0;
+ background: url("/public/destroy.png") no-repeat 0 0;
}
#item-list .todo .destroy:hover {
@@ -229,7 +229,7 @@
right: 4px;
bottom: 0;
width: 16px;
- background: url("/public/static/close_16.png") no-repeat 0 center;
+ background: url("/public/close_16.png") no-repeat 0 center;
}
#item-list .todo .item-tags .tag .remove:hover {
diff --git a/examples/todos/todos_appd/public/js/bootstrap.js b/examples/todos/todos_appd/public/js/bootstrap.js
deleted file mode 100644
index 597fa20..0000000
--- a/examples/todos/todos_appd/public/js/bootstrap.js
+++ /dev/null
@@ -1,63 +0,0 @@
-var app = app || {};
-
-(function() {
- 'use strict';
-
- var Lists = new app.Collection('lists');
- var Todos = new app.Collection('todos');
-
- app.Lists = Lists;
- app.Todos = Todos;
-
- // Copied from meteor/todos/server/bootstrap.js.
- var data = [
- {name: 'Meteor Principles',
- contents: [
- ['Data on the Wire', 'Simplicity', 'Better UX', 'Fun'],
- ['One Language', 'Simplicity', 'Fun'],
- ['Database Everywhere', 'Simplicity'],
- ['Latency Compensation', 'Better UX'],
- ['Full Stack Reactivity', 'Better UX', 'Fun'],
- ['Embrace the Ecosystem', 'Fun'],
- ['Simplicity Equals Productivity', 'Simplicity', 'Fun']
- ]
- },
- {name: 'Languages',
- contents: [
- ['Lisp', 'GC'],
- ['C', 'Linked'],
- ['C++', 'Objects', 'Linked'],
- ['Python', 'GC', 'Objects'],
- ['Ruby', 'GC', 'Objects'],
- ['JavaScript', 'GC', 'Objects'],
- ['Scala', 'GC', 'Objects'],
- ['Erlang', 'GC'],
- ['6502 Assembly', 'Linked']
- ]
- },
- {name: 'Favorite Scientists',
- contents: [
- ['Ada Lovelace', 'Computer Science'],
- ['Grace Hopper', 'Computer Science'],
- ['Marie Curie', 'Physics', 'Chemistry'],
- ['Carl Friedrich Gauss', 'Math', 'Physics'],
- ['Nikola Tesla', 'Physics'],
- ['Claude Shannon', 'Math', 'Computer Science']
- ]
- }
- ];
-
- var timestamp = (new Date()).getTime();
- for (var i = 0; i < data.length; i++) {
- var listId = Lists.insert({name: data[i].name});
- for (var j = 0; j < data[i].contents.length; j++) {
- var info = data[i].contents[j];
- Todos.insert({listId: listId,
- text: info[0],
- done: false,
- timestamp: timestamp,
- tags: info.slice(1)});
- timestamp += 1; // ensure unique timestamp
- }
- }
-}());
diff --git a/examples/todos/todos_appd/public/js/collection.js b/examples/todos/todos_appd/public/js/collection.js
deleted file mode 100644
index 8e6443d..0000000
--- a/examples/todos/todos_appd/public/js/collection.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// TODO: Use minimongo?
-
-var app = app || {};
-
-(function() {
- 'use strict';
-
- var CHANGE = 'change';
-
- var BaseEvent = function(type) {
- this.type = type;
- };
-
- var ChangeEvent = function() {
- BaseEvent.bind(this)(CHANGE);
- };
-
- app.Collection = function(name) {
- this.name_ = name;
- this.vals_ = [];
-
- this.listeners_ = {};
- this.listeners_[CHANGE] = [];
- };
-
- app.Collection.prototype = {
- find: function(q, opts) {
- var that = this;
- q = this.normalize_(q);
- var res = _.filter(this.vals_, function(v) {
- return that.matches_(v, q);
- });
- if (opts.sort) {
- // TODO: Eliminate simplifying assumptions.
- var keys = _.keys(opts.sort);
- console.assert(keys.length === 1);
- var key = keys[0];
- console.assert(opts.sort[key] === 1);
- res = res.sort(function(a, b) {
- // TODO: Verify and enhance comparator.
- return a[key] > b[key];
- });
- }
- return _.cloneDeep(res);
- },
- findOne: function(q, opts) {
- var all = this.find(q, opts);
- if (all.length > 0) {
- return all[0];
- }
- return null;
- },
- insert: function(v) {
- console.assert(!_.has(v, '_id'));
- v = _.assign({}, v, {_id: this.vals_.length});
- this.vals_.push(v);
- this.dispatchEvent_(new ChangeEvent());
- return v._id;
- },
- remove: function(q) {
- var that = this;
- q = this.normalize_(q);
- this.vals_ = _.filter(this.vals_, function(v) {
- return !that.matches_(v, q);
- });
- this.dispatchEvent_(new ChangeEvent());
- },
- update: function(q, opts) {
- var that = this;
- q = this.normalize_(q);
- var vals = _.filter(this.vals_, function(v) {
- return that.matches_(v, q);
- });
-
- // TODO: Eliminate simplifying assumptions.
- var keys = _.keys(opts);
- console.assert(keys.length === 1);
- var key = keys[0];
- console.assert(_.contains(['$addToSet', '$pull', '$set'], key));
- var opt = opts[key];
- var fields = _.keys(opt);
- console.assert(keys.length === 1);
- var field = fields[0];
-
- _.each(vals, function(val) {
- switch (key) {
- case '$addToSet':
- val[field] = _.union(val[field], [opt[field]]);
- break;
- case '$pull':
- val[field] = _.without(val[field], opt[field]);
- break;
- case '$set':
- val[field] = opt[field];
- break;
- }
- });
-
- this.dispatchEvent_(new ChangeEvent());
- },
- addEventListener: function(type, handler) {
- this.listeners_[type].push(handler);
- },
- removeEventListener: function(type, handler) {
- this.listeners_[type] = _.without(this.listeners_[type], handler);
- },
- onChange: function(handler) {
- this.addEventListener(CHANGE, handler);
- },
- normalize_: function(q) {
- if (_.isObject(q)) {
- return q;
- }
- return {_id: q};
- },
- matches_: function(v, q) {
- var keys = _.keys(q);
- for (var i = 0; i < keys.length; i++) {
- var key = keys[i];
- if (_.isArray(v[key]) && !_.isArray(q[key])) {
- if (!_.contains(v[key], q[key])) {
- return false;
- }
- } else {
- if (q[key] !== v[key]) {
- return false;
- }
- }
- }
- return true;
- },
- dispatchEvent_: function(e) {
- _.each(this.listeners_[e.type], function(handler) {
- handler(e);
- });
- }
- };
-}());
diff --git a/examples/todos/todos_appd/public/js/dispatcher.js b/examples/todos/todos_appd/public/js/dispatcher.js
deleted file mode 100644
index addb42d..0000000
--- a/examples/todos/todos_appd/public/js/dispatcher.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// Note, this is a mix of React Actions, Dispatcher, and Stores.
-
-var app = app || {};
-
-(function() {
- 'use strict';
-
- app.Dispatcher = function() {
- };
-
- app.Dispatcher.prototype = {
- addList: function(name) {
- return app.Lists.insert({name: name});
- },
- editListName: function(listId, name) {
- app.Lists.update(listId, {$set: {name: name}});
- },
- addTodo: function(listId, text, tags) {
- return app.Todos.insert({
- listId: listId,
- text: text,
- done: false,
- timestamp: (new Date()).getTime(),
- tags: tags
- });
- },
- removeTodo: function(todoId) {
- app.Todos.remove(todoId);
- },
- editTodoText: function(todoId, text) {
- app.Todos.update(todoId, {$set: {text: text}});
- },
- markTodoDone: function(todoId, done) {
- app.Todos.update(todoId, {$set: {done: done}});
- },
- addTag: function(todoId, tag) {
- app.Todos.update(todoId, {$addToSet: {tags: tag}});
- },
- removeTag: function(todoId, tag) {
- app.Todos.update(todoId, {$pull: {tags: tag}});
- }
- };
-}());
diff --git a/examples/todos/todos_appd/public/js/index.js b/examples/todos/todos_appd/public/js/index.js
deleted file mode 100644
index d966443..0000000
--- a/examples/todos/todos_appd/public/js/index.js
+++ /dev/null
@@ -1,445 +0,0 @@
-var app = app || {};
-
-(function() {
- 'use strict';
-
- ////////////////////////////////////////
- // Helpers
-
- var d = new app.Dispatcher();
-
- var activateInput = function(input) {
- input.focus();
- input.select();
- };
-
- var okCancelEvents = function(callbacks) {
- var ok = callbacks.ok || function() {};
- var cancel = callbacks.cancel || function() {};
- var done = function(ev) {
- var value = ev.target.value;
- if (value) {
- ok(value, ev);
- } else {
- cancel(ev);
- }
- };
- return {
- onKeyDown: function(ev) {
- if (ev.which === 27) { // esc
- cancel(ev);
- }
- },
- onKeyUp: function(ev) {
- if (ev.which === 13) { // enter
- done(ev);
- }
- },
- onBlur: function(ev) {
- done(ev);
- }
- };
- };
-
- ////////////////////////////////////////
- // Components
-
- var TagFilter = React.createClass({
- displayName: 'TagFilter',
- render: function() {
- var that = this;
- var tagFilter = this.props.tagFilter;
- var tagInfos = [], totalCount = 0;
- _.each(this.props.todos, function(todo) {
- _.each(todo.tags, function(tag) {
- var tagInfo = _.find(tagInfos, function(x) {
- return x.tag === tag;
- });
- if (!tagInfo) {
- tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag});
- } else {
- tagInfo.count++;
- }
- });
- totalCount++;
- });
- tagInfos = _.sortBy(tagInfos, function(x) { return x.tag; });
- // Note, the 'All items' tag handling is fairly convoluted in Meteor.
- tagInfos.unshift({
- tag: null,
- count: totalCount,
- selected: tagFilter === null
- });
-
- var children = [];
- _.each(tagInfos, function(tagInfo) {
- var count = React.DOM.span(
- {className: 'count'}, '(' + tagInfo.count + ')');
- children.push(React.DOM.div({
- className: 'tag' + (tagInfo.selected ? ' selected' : ''),
- onMouseDown: function() {
- var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
- that.props.setTagFilter(newTagFilter);
- }
- }, tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count));
- });
- return React.DOM.div(
- {id: 'tag-filter', className: 'tag-list'},
- React.DOM.div({className: 'label'}, 'Show:'),
- children);
- }
- });
-
- var Tags = React.createClass({
- displayName: 'Tags',
- getInitialState: function() {
- return {
- addingTag: false
- };
- },
- componentDidUpdate: function() {
- if (this.state.addingTag) {
- activateInput(this.getDOMNode().querySelector('#edittag-input'));
- }
- },
- render: function() {
- var that = this;
- var children = [];
- _.each(this.props.tags, function(tag) {
- // Note, we must specify the "key" prop so that React doesn't reuse the
- // opacity=0 node after a tag is removed.
- children.push(React.DOM.div(
- {className: 'tag removable_tag', key: tag},
- React.DOM.div({className: 'name'}, tag),
- React.DOM.div({
- className: 'remove',
- onClick: function(ev) {
- ev.target.parentNode.style.opacity = 0;
- // Wait for CSS animation to finish.
- window.setTimeout(function() {
- d.removeTag(that.props.todoId, tag);
- }, 300);
- }
- })));
- });
- if (this.state.addingTag) {
- children.push(React.DOM.div(
- {className: 'tag edittag'},
- React.DOM.input(_.assign({
- type: 'text',
- id: 'edittag-input',
- defaultValue: ''
- }, okCancelEvents({
- ok: function(value) {
- d.addTag(that.props.todoId, value);
- that.setState({addingTag: false});
- },
- cancel: function() {
- that.setState({addingTag: false});
- }
- })))));
- } else {
- children.push(React.DOM.div({
- className: 'tag addtag',
- onClick: function() {
- that.setState({addingTag: true});
- }
- }, '+tag'));
- }
- return React.DOM.div({className: 'item-tags'}, children);
- }
- });
-
- var Todo = React.createClass({
- displayName: 'Todo',
- getInitialState: function() {
- return {
- editingText: false
- };
- },
- componentDidUpdate: function() {
- if (this.state.editingText) {
- activateInput(this.getDOMNode().querySelector('#todo-input'));
- }
- },
- render: function() {
- var that = this;
- var todo = this.props.todo, children = [];
- if (this.state.editingText) {
- children.push(React.DOM.div(
- {className: 'edit'},
- React.DOM.input(_.assign({
- id: 'todo-input',
- type: 'text',
- defaultValue: todo.text
- }, okCancelEvents({
- ok: function(value) {
- d.editTodoText(todo._id, value);
- that.setState({editingText: false});
- },
- cancel: function() {
- that.setState({editingText: false});
- }
- })))));
- } else {
- children.push(React.DOM.div({
- className: 'destroy',
- onClick: function() {
- d.removeTodo(todo._id);
- }
- }));
- children.push(React.DOM.div(
- {className: 'display'},
- React.DOM.input({
- className: 'check',
- name: 'markdone',
- type: 'checkbox',
- checked: todo.done,
- onClick: function() {
- d.markTodoDone(!todo.done);
- }
- }),
- React.DOM.div({
- className: 'todo-text',
- onDoubleClick: function() {
- that.setState({editingText: true});
- }
- }, todo.text)));
- }
- children.push(new Tags({todoId: todo._id, tags: todo.tags}));
- return React.DOM.li({
- className: 'todo' + (todo.done ? ' done' : '')
- }, children);
- }
- });
-
- var Todos = React.createClass({
- displayName: 'Todos',
- render: function() {
- var that = this;
- if (this.props.listId === null) {
- return null;
- }
- var children = [];
- if (this.props.todos === null) {
- children.push('Loading...');
- } else {
- var tagFilter = this.props.tagFilter, items = [];
- _.each(this.props.todos, function(todo) {
- if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
- items.push(new Todo({todo: todo}));
- }
- });
- children.push(React.DOM.div(
- {id: 'new-todo-box'},
- React.DOM.input(_.assign({
- type: 'text',
- id: 'new-todo',
- placeholder: 'New item'
- }, okCancelEvents({
- ok: function(value, ev) {
- var tags = tagFilter ? [tagFilter] : [];
- d.addTodo(that.props.listId, value, tags);
- ev.target.value = '';
- }
- })))));
- children.push(React.DOM.ul({id: 'item-list'}, items));
- }
- return React.DOM.div({id: 'items-view'}, children);
- }
- });
-
- var List = React.createClass({
- displayName: 'List',
- getInitialState: function() {
- return {
- editingName: false
- };
- },
- componentDidUpdate: function() {
- if (this.state.editingName) {
- activateInput(this.getDOMNode().querySelector('#list-name-input'));
- }
- },
- render: function() {
- var that = this;
- var list = this.props.list, child;
- // http://facebook.github.io/react/docs/forms.html#controlled-components
- if (this.state.editingName) {
- child = React.DOM.div(
- {className: 'edit'},
- React.DOM.input(_.assign({
- className: 'list-name-input',
- id: 'list-name-input',
- type: 'text',
- defaultValue: list.name
- }, okCancelEvents({
- ok: function(value) {
- d.editListName(list._id, value);
- that.setState({editingName: false});
- },
- cancel: function() {
- that.setState({editingName: false});
- }
- }))));
- } else {
- child = React.DOM.div(
- {className: 'display'},
- React.DOM.a({
- className: 'list-name' + (list.name ? '' : ' empty'),
- href: '/' + list._id
- }, list.name));
- }
- return React.DOM.div({
- className: 'list' + (list.selected ? ' selected' : ''),
- onMouseDown: function() {
- that.props.setListId(list._id);
- },
- onClick: function(ev) {
- ev.preventDefault(); // prevent page refresh
- },
- onDoubleClick: function() {
- that.setState({editingName: true});
- }
- }, child);
- }
- });
-
- var Lists = React.createClass({
- displayName: 'Lists',
- render: function() {
- var that = this;
- var children = [React.DOM.h3({}, 'Todo Lists')];
- if (this.props.lists === null) {
- children.push(React.DOM.div({id: 'lists'}, 'Loading...'));
- } else {
- var lists = [];
- _.each(this.props.lists, function(list) {
- list.selected = that.props.listId === list._id;
- lists.push(new List({
- list: list,
- setListId: that.props.setListId
- }));
- });
- children.push(React.DOM.div({id: 'lists'}, lists));
- children.push(React.DOM.div(
- {id: 'createList'},
- React.DOM.input(_.assign({
- type: 'text',
- id: 'new-list',
- placeholder: 'New list'
- }, okCancelEvents({
- ok: function(value, ev) {
- var id = d.addList(value);
- that.props.setListId(id);
- ev.target.value = '';
- }
- })))));
- }
- return React.DOM.div({}, children);
- }
- });
-
- var Page = React.createClass({
- displayName: 'Page',
- getInitialState: function() {
- return {
- lists: null, // all lists
- todos: null, // all todos for current listId
- listId: this.props.initialListId, // current list
- tagFilter: null // current tag
- };
- },
- fetchLists: function() {
- return app.Lists.find({}, {sort: {name: 1}});
- },
- fetchTodos: function(listId) {
- if (listId === null) {
- return null;
- }
- return app.Todos.find({listId: listId}, {sort: {timestamp: 1}});
- },
- updateURL: function() {
- var router = this.props.router, listId = this.state.listId;
- router.navigate(listId === null ? '' : String(listId));
- },
- componentDidMount: function() {
- var that = this;
- var lists = this.fetchLists();
- var listId = this.state.listId;
- if (listId === null && lists.length > 0) {
- listId = lists[0]._id;
- }
- this.setState({
- lists: lists,
- todos: this.fetchTodos(listId),
- listId: listId
- });
- this.updateURL();
-
- app.Lists.onChange(function() {
- that.setState({lists: that.fetchLists()});
- });
- app.Todos.onChange(function() {
- that.setState({todos: that.fetchTodos(that.state.listId)});
- });
- },
- componentDidUpdate: function() {
- this.updateURL();
- },
- render: function() {
- var that = this;
- return React.DOM.div({}, [
- React.DOM.div({id: 'top-tag-filter'}, new TagFilter({
- todos: this.state.todos,
- tagFilter: this.state.tagFilter,
- setTagFilter: function(tagFilter) {
- that.setState({tagFilter: tagFilter});
- }
- })),
- React.DOM.div({id: 'main-pane'}, new Todos({
- todos: this.state.todos,
- listId: this.state.listId,
- tagFilter: this.state.tagFilter
- })),
- React.DOM.div({id: 'side-pane'}, new Lists({
- lists: this.state.lists,
- listId: this.state.listId,
- setListId: function(listId) {
- if (listId !== that.state.listId) {
- that.setState({
- todos: that.fetchTodos(listId),
- listId: listId
- });
- }
- }
- }))
- ]);
- }
- });
-
- ////////////////////////////////////////
- // Initialization
-
- app.init = function() {
- var Router = Backbone.Router.extend({
- routes: {
- '': 'main',
- ':listId': 'main'
- }
- });
- var router = new Router();
-
- var page;
- router.on('route:main', function(listId) {
- console.assert(!page);
- if (listId !== null) {
- listId = Number(listId);
- }
- page = new Page({router: router, initialListId: listId});
- React.renderComponent(page, document.getElementById('c'));
- });
-
- Backbone.history.start({pushState: true});
- };
-}());
diff --git a/examples/todos/todos_appd/server.js b/examples/todos/todos_appd/server.js
index f1416dd..9abd8c4 100644
--- a/examples/todos/todos_appd/server.js
+++ b/examples/todos/todos_appd/server.js
@@ -10,10 +10,15 @@
}
app.use('/public', express.static(pathTo('public')));
+app.use('/third_party', express.static(pathTo('third_party')));
-app.get('*', function(req, res) {
+var routes = ['/', '/lists/*'];
+var handler = function(req, res) {
res.sendfile('index.html');
-});
+};
+for (var i = 0; i < routes.length; i++) {
+ app.get(routes[i], handler);
+}
var server = app.listen(4000, function() {
console.log('Serving http://localhost:%d', server.address().port);
diff --git a/examples/todos/todos_appd/public/third_party/backbone-min.js b/examples/todos/todos_appd/third_party/backbone-min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/backbone-min.js
rename to examples/todos/todos_appd/third_party/backbone-min.js
diff --git a/examples/todos/todos_appd/public/third_party/jquery-2.1.1.min.js b/examples/todos/todos_appd/third_party/jquery-2.1.1.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/jquery-2.1.1.min.js
rename to examples/todos/todos_appd/third_party/jquery-2.1.1.min.js
diff --git a/examples/todos/todos_appd/public/third_party/lodash.min.js b/examples/todos/todos_appd/third_party/lodash.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/lodash.min.js
rename to examples/todos/todos_appd/third_party/lodash.min.js
diff --git a/examples/todos/todos_appd/public/third_party/react-0.11.1.js b/examples/todos/todos_appd/third_party/react-0.11.1.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/react-0.11.1.js
rename to examples/todos/todos_appd/third_party/react-0.11.1.js
diff --git a/examples/todos/todos_appd/public/third_party/react-0.11.1.min.js b/examples/todos/todos_appd/third_party/react-0.11.1.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/react-0.11.1.min.js
rename to examples/todos/todos_appd/third_party/react-0.11.1.min.js
diff --git a/services/wsprd/wspr.go b/services/wsprd/wspr.go
index 4a01ea0..76efed0 100644
--- a/services/wsprd/wspr.go
+++ b/services/wsprd/wspr.go
@@ -5,14 +5,18 @@
"veyron/lib/signals"
"veyron/services/wsprd/wspr"
+ "veyron2/rt"
)
func main() {
- port := flag.Int("port", 8124, "Port to listen on")
- veyronProxy := flag.String("vproxy", "", "The endpoint for the veyron proxy to publish on. This must be set")
+ port := flag.Int("port", 8124, "Port to listen on.")
+ veyronProxy := flag.String("vproxy", "", "The endpoint for the veyron proxy to publish on. This must be set.")
+ identd := flag.String("identd", "", "The endpoint for the identd server. This must be set.")
flag.Parse()
- proxy := wspr.NewWSPR(*port, *veyronProxy)
+ rt.Init()
+
+ proxy := wspr.NewWSPR(*port, *veyronProxy, *identd)
defer proxy.Shutdown()
go func() {
proxy.Run()
diff --git a/services/wsprd/wspr/pipe.go b/services/wsprd/wspr/pipe.go
index 8c6dae9..c6f981c 100644
--- a/services/wsprd/wspr/pipe.go
+++ b/services/wsprd/wspr/pipe.go
@@ -2,7 +2,6 @@
import (
"bytes"
- "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -15,7 +14,6 @@
"veyron/services/wsprd/app"
"veyron/services/wsprd/lib"
"veyron2"
- "veyron2/security"
"veyron2/verror"
"veyron2/vlog"
"veyron2/vom"
@@ -262,37 +260,3 @@
}
p.cleanup()
}
-
-func decodeIdentity(logger vlog.Logger, msg string) security.PrivateID {
- if len(msg) == 0 {
- return nil
- }
- // PrivateIds are sent as base64-encoded-vom-encoded identity.PrivateID.
- // Pure JSON or pure VOM could not have been used.
- // - JSON cannot be used because identity.PrivateID contains an
- // ecdsa.PrivateKey (which encoding/json cannot decode).
- // - Regular VOM cannot be used because it only has a binary,
- // Go-specific implementation at this time.
- // The "portable" encoding is base64-encoded VOM (see
- // veyron/daemon/cmd/identity/responder/responder.go).
- // When toddw@ has the text-based VOM encoding going, that can probably
- // be used instead.
- var id security.PrivateID
- if err := vom.NewDecoder(base64.NewDecoder(base64.URLEncoding, strings.NewReader(msg))).Decode(&id); err != nil {
- logger.Error("Could not decode identity:", err)
- return nil
- }
- return id
-}
-
-func encodeIdentity(logger vlog.Logger, identity security.PrivateID) string {
- var vomEncoded bytes.Buffer
- if err := vom.NewEncoder(&vomEncoded).Encode(identity); err != nil {
- logger.Error("Could not encode identity: %v", err)
- }
- var base64Encoded bytes.Buffer
- encoder := base64.NewEncoder(base64.URLEncoding, &base64Encoded)
- encoder.Write(vomEncoded.Bytes())
- encoder.Close()
- return base64Encoded.String()
-}
diff --git a/services/wsprd/wspr/pipe_test.go b/services/wsprd/wspr/pipe_test.go
deleted file mode 100644
index 8a271c6..0000000
--- a/services/wsprd/wspr/pipe_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package wspr
-
-import (
- "testing"
- "veyron/services/wsprd/lib"
- "veyron2"
- "veyron2/rt"
- "veyron2/security"
-)
-
-var r veyron2.Runtime
-
-func init() {
- r = rt.Init()
-}
-
-type testWriter struct{}
-
-func (*testWriter) Send(lib.ResponseType, interface{}) error { return nil }
-func (*testWriter) Error(error) {}
-
-func TestEncodeDecodeIdentity(t *testing.T) {
- identity := security.FakePrivateID("/fake/private/id")
- resultIdentity := decodeIdentity(r.Logger(), encodeIdentity(r.Logger(), identity))
- if identity != resultIdentity {
- t.Errorf("expected decodeIdentity(encodeIdentity(identity)) to be %v, got %v", identity, resultIdentity)
- }
-}
diff --git a/services/wsprd/wspr/wspr.go b/services/wsprd/wspr/wspr.go
index 06c1ffb..6d238fe 100644
--- a/services/wsprd/wspr/wspr.go
+++ b/services/wsprd/wspr/wspr.go
@@ -17,6 +17,7 @@
import (
"bytes"
"crypto/tls"
+ "encoding/json"
"fmt"
"io"
"log"
@@ -25,9 +26,11 @@
"sync"
"time"
+ veyron_identity "veyron/services/identity"
"veyron/services/wsprd/identity"
"veyron2"
"veyron2/rt"
+ "veyron2/security"
"veyron2/vlog"
)
@@ -41,31 +44,20 @@
}
type WSPR struct {
- mu sync.Mutex
- tlsCert *tls.Certificate
- rt veyron2.Runtime
- logger vlog.Logger
- port int
- veyronProxyEP string
- idManager *identity.IDManager
- pipes map[*http.Request]*pipe
+ mu sync.Mutex
+ tlsCert *tls.Certificate
+ rt veyron2.Runtime
+ logger vlog.Logger
+ port int
+ identdEP string
+ veyronProxyEP string
+ idManager *identity.IDManager
+ blesserService veyron_identity.OAuthBlesser
+ pipes map[*http.Request]*pipe
}
var logger vlog.Logger
-func (ctx WSPR) handleDebug(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html")
- w.Write([]byte(`<html>
-<head>
-<title>/debug</title>
-</head>
-<body>
-<ul>
-<li><a href="/debug/pprof">/debug/pprof</a></li>
-</li></ul></body></html>
-`))
-}
-
func readFromRequest(r *http.Request) (*bytes.Buffer, error) {
var buf bytes.Buffer
if readBytes, err := io.Copy(&buf, r.Body); err != nil {
@@ -82,19 +74,23 @@
// Starts the proxy and listens for requests. This method is blocking.
func (ctx WSPR) Run() {
- http.HandleFunc("/debug", ctx.handleDebug)
- http.Handle("/favicon.ico", http.NotFoundHandler())
- http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
- ctx.logger.VI(0).Info("Creating a new websocket")
- p := newPipe(w, r, &ctx, nil)
+ // Bind to the OAuth Blesser service
+ blesserService, err := veyron_identity.BindOAuthBlesser(ctx.identdEP)
+ if err != nil {
+ log.Fatalf("Failed to bind to identity service at %v: %v", ctx.identdEP, err)
+ }
+ ctx.blesserService = blesserService
- if p == nil {
- return
- }
- ctx.mu.Lock()
- defer ctx.mu.Unlock()
- ctx.pipes[r] = p
- })
+ // HTTP routes
+ http.HandleFunc("/debug", ctx.handleDebug)
+ http.HandleFunc("/create-account", ctx.handleCreateAccount)
+ http.HandleFunc("/assoc-account", ctx.handleAssocAccount)
+ http.HandleFunc("/ws", ctx.handleWS)
+ // Everything else is a 404.
+ // Note: the pattern "/" matches all paths not matched by other
+ // registered patterns, not just the URL with Path == "/".'
+ // (http://golang.org/pkg/net/http/#ServeMux)
+ http.Handle("/", http.NotFoundHandler())
ctx.logger.VI(1).Infof("Listening on port %d.", ctx.port)
httpErr := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", ctx.port), nil)
if httpErr != nil {
@@ -113,10 +109,13 @@
}
// Creates a new WebSocket Proxy object.
-func NewWSPR(port int, veyronProxyEP string, opts ...veyron2.ROpt) *WSPR {
+func NewWSPR(port int, veyronProxyEP, identdEP string, opts ...veyron2.ROpt) *WSPR {
if veyronProxyEP == "" {
log.Fatalf("a veyron proxy must be set")
}
+ if identdEP == "" {
+ log.Fatalf("an identd server must be set")
+ }
newrt, err := rt.New(opts...)
if err != nil {
@@ -131,8 +130,149 @@
return &WSPR{port: port,
veyronProxyEP: veyronProxyEP,
+ identdEP: identdEP,
rt: newrt,
logger: newrt.Logger(),
idManager: idManager,
}
}
+
+// HTTP Handlers
+
+func (ctx WSPR) handleDebug(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ fmt.Fprintf(w, "")
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`<html>
+<head>
+<title>/debug</title>
+</head>
+<body>
+<ul>
+<li><a href="/debug/pprof">/debug/pprof</a></li>
+</li></ul></body></html>
+`))
+}
+
+func (ctx WSPR) handleWS(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+ return
+ }
+ ctx.logger.VI(0).Info("Creating a new websocket")
+ p := newPipe(w, r, &ctx, nil)
+
+ if p == nil {
+ return
+ }
+ ctx.mu.Lock()
+ defer ctx.mu.Unlock()
+ ctx.pipes[r] = p
+}
+
+// Structs for marshalling input/output to create-account route.
+type createAccountInput struct {
+ AccessToken string `json:access_token`
+}
+
+type createAccountOutput struct {
+ Names []string `json:names`
+}
+
+// Handler for creating an account in the identity manager.
+// A valid OAuth2 access token must be supplied in the request body. That
+// access token is exchanged for a blessing from the identd server. A new
+// privateID is then derived from WSPR's privateID and the blessing. That
+// privateID is stored in the identity manager. The name of the new privateID
+// is returned to the client.
+func (ctx WSPR) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse request body.
+ var data createAccountInput
+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ msg := fmt.Sprintf("Error parsing body: %v", err)
+ ctx.logger.Error(msg)
+ http.Error(w, msg, http.StatusBadRequest)
+ }
+
+ // Get a blessing for the access token from identity server.
+ blessingAny, err := ctx.blesserService.BlessUsingAccessToken(ctx.rt.NewContext(), data.AccessToken)
+ if err != nil {
+ msg := fmt.Sprintf("Error getting blessing for access token: %v", err)
+ ctx.logger.Error(msg)
+ http.Error(w, msg, http.StatusBadRequest)
+ return
+ }
+ blessing := blessingAny.(security.PublicID)
+
+ // Derive a new identity from the runtime's identity and the blessing.
+ identity, err := ctx.rt.Identity().Derive(blessing)
+ if err != nil {
+ msg := fmt.Sprintf("Error deriving identity: %v", err)
+ ctx.logger.Error(msg)
+ http.Error(w, msg, http.StatusBadRequest)
+ return
+ }
+
+ for _, name := range blessing.Names() {
+ // Store identity in identity manager.
+ if err := ctx.idManager.AddAccount(name, identity); err != nil {
+ msg := fmt.Sprintf("Error storing identity: %v", err)
+ ctx.logger.Error(msg)
+ http.Error(w, msg, http.StatusBadRequest)
+ return
+ }
+ }
+
+ // Return the names to the client.
+ out := createAccountOutput{
+ Names: blessing.Names(),
+ }
+ outJson, err := json.Marshal(out)
+ if err != nil {
+ msg := fmt.Sprintf("Error mashalling names: %v", err)
+ ctx.logger.Error(msg)
+ http.Error(w, msg, http.StatusInternalServerError)
+ return
+ }
+
+ // Success.
+ fmt.Fprintf(w, string(outJson))
+}
+
+// Struct for marshalling input to assoc-account route.
+type assocAccountInput struct {
+ Name string `json:name`
+ Origin string `json:origin`
+}
+
+// Handler for associating an existing privateID with an origin.
+func (ctx WSPR) handleAssocAccount(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse request body.
+ var data assocAccountInput
+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ http.Error(w, fmt.Sprintf("Error parsing body: %v", err), http.StatusBadRequest)
+ }
+
+ // Store the origin.
+ // TODO(nlacasse, bjornick): determine what the caveats should be.
+ if err := ctx.idManager.AddOrigin(data.Origin, data.Name, nil); err != nil {
+ http.Error(w, fmt.Sprintf("Error associating account: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Success.
+ fmt.Fprintf(w, "")
+}
diff --git a/services/wsprd/wspr/wspr_test.go b/services/wsprd/wspr/wspr_test.go
new file mode 100644
index 0000000..2677a54
--- /dev/null
+++ b/services/wsprd/wspr/wspr_test.go
@@ -0,0 +1,227 @@
+package wspr
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "veyron2/context"
+ "veyron2/ipc"
+ "veyron2/security"
+ "veyron2/vdl/vdlutil"
+)
+
+// BEGIN MOCK BLESSER SERVICE
+// TODO(nlacasse): Is there a better way to mock this?!
+type mockBlesserService struct {
+ id security.PrivateID
+ count int
+}
+
+func newMockBlesserService(id security.PrivateID) *mockBlesserService {
+ return &mockBlesserService{
+ id: id,
+ count: 0,
+ }
+}
+
+func (m *mockBlesserService) BlessUsingAccessToken(c context.T, accessToken string, co ...ipc.CallOpt) (vdlutil.Any, error) {
+ m.count = m.count + 1
+ name := fmt.Sprintf("mock-blessing-%v", m.count)
+ return m.id.Bless(m.id.PublicID(), name, 5*time.Minute, nil)
+}
+
+// This is never used. Only needed for mock.
+func (m *mockBlesserService) BlessUsingAuthorizationCode(c context.T, authCode, redirect string, co ...ipc.CallOpt) (vdlutil.Any, error) {
+ return m.id.PublicID(), nil
+}
+
+// This is never used. Only needed for mock.
+func (m *mockBlesserService) GetMethodTags(c context.T, s string, co ...ipc.CallOpt) ([]interface{}, error) {
+ return nil, nil
+}
+
+// This is never used. Only needed for mock.
+func (m *mockBlesserService) Signature(c context.T, co ...ipc.CallOpt) (ipc.ServiceSignature, error) {
+ return ipc.ServiceSignature{}, nil
+}
+
+// This is never used. Only needed for mock.
+func (m *mockBlesserService) UnresolveStep(c context.T, co ...ipc.CallOpt) ([]string, error) {
+ return []string{}, nil
+}
+
+// END MOCK BLESSER SERVICE
+
+func setup(t *testing.T) (*WSPR, func()) {
+ wspr := NewWSPR(0, "/mock/proxy", "/mock/identd")
+ providerId := wspr.rt.Identity()
+
+ wspr.blesserService = newMockBlesserService(providerId)
+ return wspr, func() {
+ wspr.Shutdown()
+ }
+}
+
+func TestHandleCreateAccount(t *testing.T) {
+ wspr, teardown := setup(t)
+ defer teardown()
+
+ method := "POST"
+ path := "/create-account"
+
+ // Add one account
+ data1 := createAccountInput{
+ AccessToken: "mock-access-token-1",
+ }
+ data1Json, err := json.Marshal(data1)
+ if err != nil {
+ t.Fatalf("json.Marshal(%v) failed: %v", data1, err)
+ }
+
+ data1JsonReader := bytes.NewReader(data1Json)
+ req, err := http.NewRequest(method, path, (data1JsonReader))
+ if err != nil {
+ t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, data1JsonReader, err)
+ }
+
+ resp1 := httptest.NewRecorder()
+ wspr.handleCreateAccount(resp1, req)
+ if resp1.Code != 200 {
+ t.Fatalf("Expected handleCreateAccount to return 200 OK, instead got %v", resp1)
+ }
+
+ // Verify that idManager has the new account
+ topLevelName := wspr.rt.Identity().PublicID().Names()[0]
+ expectedAccountName := topLevelName + "/mock-blessing-1"
+ gotAccounts := wspr.idManager.AccountsMatching(security.PrincipalPattern(expectedAccountName))
+ if len(gotAccounts) != 1 {
+ t.Fatalf("Expected to have 1 account with name %v, but got %v: %v", expectedAccountName, len(gotAccounts), gotAccounts)
+ }
+
+ // Add another account
+ data2 := createAccountInput{
+ AccessToken: "mock-access-token-2",
+ }
+ data2Json, err := json.Marshal(data2)
+ if err != nil {
+ t.Fatalf("json.Marshal(%v) failed: %v", data2, err)
+ }
+ data2JsonReader := bytes.NewReader(data2Json)
+ req, err = http.NewRequest(method, path, data2JsonReader)
+ if err != nil {
+ t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, data2JsonReader, err)
+ }
+
+ resp2 := httptest.NewRecorder()
+ wspr.handleCreateAccount(resp2, req)
+ if resp2.Code != 200 {
+ t.Fatalf("Expected handleCreateAccount to return 200 OK, instead got %v", resp2)
+ }
+
+ // Verify that idManager has both accounts
+ gotAccounts = wspr.idManager.AccountsMatching(security.PrincipalPattern(topLevelName + "/*"))
+ if len(gotAccounts) != 2 {
+ t.Fatalf("Expected to have 2 accounts, but got %v: %v", len(gotAccounts), gotAccounts)
+ }
+}
+
+func TestHandleAssocAccount(t *testing.T) {
+ wspr, teardown := setup(t)
+ defer teardown()
+
+ // First create an accounts.
+ accountName := "mock-account"
+ identityName := "mock-id"
+ privateID, err := wspr.rt.NewIdentity(identityName)
+ if err != nil {
+ t.Fatalf("wspr.rt.NewIdentity(%v) failed: %v", identityName, err)
+ }
+ if err := wspr.idManager.AddAccount(accountName, privateID); err != nil {
+ t.Fatalf("wspr.idManager.AddAccount(%v, %v) failed; %v", accountName, privateID, err)
+ }
+
+ // Associate with that account
+ method := "POST"
+ path := "/assoc-account"
+
+ origin := "https://my.webapp.com:443"
+ data := assocAccountInput{
+ Name: accountName,
+ Origin: origin,
+ }
+
+ dataJson, err := json.Marshal(data)
+ if err != nil {
+ t.Fatalf("json.Marshal(%v) failed: %v", data, err)
+ }
+
+ dataJsonReader := bytes.NewReader(dataJson)
+ req, err := http.NewRequest(method, path, (dataJsonReader))
+ if err != nil {
+ t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, dataJsonReader, err)
+ }
+
+ resp := httptest.NewRecorder()
+ wspr.handleAssocAccount(resp, req)
+ if resp.Code != 200 {
+ t.Fatalf("Expected handleAssocAccount to return 200 OK, instead got %v", resp)
+ }
+
+ // Verify that idManager has the correct identity for the origin
+ gotID, err := wspr.idManager.Identity(origin)
+ if err != nil {
+ t.Fatalf("wspr.idManager.Identity(%v) failed: %v", origin, err)
+ }
+
+ if gotID == nil {
+ t.Fatalf("Expected wspr.idManager.Identity(%v) to return an valid identity, but got %v", origin, gotID)
+ }
+}
+
+func TestHandleAssocAccountWithMissingAccount(t *testing.T) {
+ wspr, teardown := setup(t)
+ defer teardown()
+
+ method := "POST"
+ path := "/assoc-account"
+
+ accountName := "mock-account"
+ origin := "https://my.webapp.com:443"
+ data := assocAccountInput{
+ Name: accountName,
+ Origin: origin,
+ }
+
+ dataJson, err := json.Marshal(data)
+ if err != nil {
+ t.Fatalf("json.Marshal(%v) failed: %v", data, err)
+ }
+
+ dataJsonReader := bytes.NewReader(dataJson)
+ req, err := http.NewRequest(method, path, (dataJsonReader))
+ if err != nil {
+ t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, dataJsonReader, err)
+ }
+
+ // Verify that the request fails with 400 Bad Request error
+ resp := httptest.NewRecorder()
+ wspr.handleAssocAccount(resp, req)
+ if resp.Code != 400 {
+ t.Fatalf("Expected handleAssocAccount to return 400 error, but got %v", resp)
+ }
+
+ // Verify that idManager has no identities for the origin
+ gotID, err := wspr.idManager.Identity(origin)
+ if err == nil {
+ t.Fatalf("Expected wspr.idManager.Identity(%v) to fail, but got: %v", origin, gotID)
+ }
+
+ if gotID != nil {
+ t.Fatalf("Expected wspr.idManager.Identity(%v) not to return an identity, but got %v", origin, gotID)
+ }
+}