diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..f0c0bf3 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,18 @@ +{ + "bitwise": true, + "curly": true, + "eqeqeq": true, + "esnext": true, + "freeze": true, + "immed": true, + "indent": 2, + "latedef": "nofunc", + "newcap": true, + "node": true, + "noarg": true, + "quotmark": "single", + "strict": true, + "trailing": true, + "undef": true, + "unused": true +} \ No newline at end of file diff --git a/Gulpfile.js b/Gulpfile.js deleted file mode 100644 index 22aec97..0000000 --- a/Gulpfile.js +++ /dev/null @@ -1,21 +0,0 @@ -var gulp = require('gulp'); -var Dredd = require('dredd'); -var app = require('./todos.js'); - -gulp.task('dredd', function(cb) { - var server = app.listen(8080, function() { - var dredd = new Dredd({ - blueprintPath: 'todos.apib', - server: 'http://localhost:8080', - options: { - hookfiles: 'test_hooks.js' - } - }); - dredd.run(function(error, stats){ - server.close(); - cb(); - }); - }); -}); - -gulp.task('test', ['dredd']); diff --git a/README.md b/README.md index e41a96c..c3d829a 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,109 @@ -gcloud-node-todos -================= +# gcloud-node-todos +> TodoMVC backend using [gcloud-node](//github.com/GoogleCloudPlatform/gcloud-node). [![Build Status](https://travis-ci.org/GoogleCloudPlatform/gcloud-node-todos.svg?branch=master)](https://travis-ci.org/GoogleCloudPlatform/gcloud-node-todos) -TodoMVC backend using [gcloud-node](//github.com/GoogleCloudPlatform/gcloud-node). +## Examples -# API +See the [apps](https://github.com/GoogleCloudPlatform/gcloud-node-todos/apps) directory for a full list of examples. -- Insert a todo +Have an idea for an implementation? Please [raise an issue](https://github.com/GoogleCloudPlatform/gcloud-node-todos/issues/new). - curl -X POST -d '{text: "do this"}' http://localhost:8080/todos +## API -- Get a todo +#### Insert a todo +```sh +$ curl -X POST -d '{text: "do this"}' http://localhost:8080/todos +``` - curl -X GET http://localhost:8080/todos/{{todo id}} +#### Get a todo +```sh +$ curl -X GET http://localhost:8080/todos/{{todo id}} +``` -- Mark a todo as done +#### Mark a todo as done +```sh +$ curl -X PUT -d '{text: "do this", "done": true}' http://localhost:8080/todos/{{todo id}} +``` - curl -X PUT -d '{text: "do this", "done": true}' http://localhost:8080/todos/{{todo id}} +#### Delete a todo +```sh +$ curl -X DELETE http://localhost:8080/todos/{{todo id}} +``` -- Delete a todo +#### Get all todos +```sh +$ curl -X GET http://localhost:8080/todos +``` - curl -X DELETE http://localhost:8080/todos/{{todo id}} +#### Clear all `done` todos +```sh +$ curl -X DELETE http://localhost:8080/todos +``` -- Get all todos - - curl -X GET http://localhost:8080/todos +## Prerequisites -- Clear all `done` todos +1. Create a new cloud project on [console.developers.google.com](http://console.developers.google.com) +2. [Enable](https://console.developers.google.com/flows/enableapi?apiid=datastore) the [Google Cloud Datastore API](https://developers.google.com/datastore) +3. Create a new service account and copy the JSON credentials to `key.json` +4. Export your project id: + ```sh + $ export PROJECT_ID= + ``` - curl -X DELETE http://localhost:8080/todos +## Running -# Prerequisites +#### Locally +```sh +# Set your default Dataset +$ export DATASET_ID=$PROJECT_ID - - Create a new cloud project on [console.developers.google.com](http://console.developers.google.com) - - [Enable](https://console.developers.google.com/flows/enableapi?apiid=datastore) the [Google Cloud Datastore API](https://developers.google.com/datastore) - - Create a new service account and copy the `JSON` credentials to `key.json` - - Export your project id - - export PROJECT_ID= +# Install the dependencies +$ npm install -# Run locally +# Start the server +$ npm start - # set your default dataset - export DATASET_ID=$PROJECT_ID - # fetch the dependencies - npm install - # start the app - npm start - # run acceptance test - dredd todos.apib http://localhost:8080 --hookfiles test_hooks.js +# Run acceptance test +$ npm test +``` -# Run in docker +#### Docker +```sh +# Check that Docker is running +$ boot2docker up +$ export DOCKER_HOST=$(boot2docker shellinit) - # check that docker is running - boot2docker up - export DOCKER_HOST=$(boot2docker shellinit) +# Build your Docker image +$ docker build -t app . - # build your docker image - docker build -t app . - # start a new docker container - docker run -e DATASET_ID=$PROJECT_ID -p 8080:8080 app +# Start a new Docker container +$ docker run -e DATASET_ID=$PROJECT_ID -p 8080:8080 app - # test the app - curl -X GET http://$(boot2docker ip):8080 +# Test the app +$ curl -X GET http://$(boot2docker ip):8080 +``` -# Run w/ [Managed VMs](https://developers.google.com/appengine/docs/managed-vms/) +#### [Managed VMs](https://developers.google.com/appengine/docs/managed-vms/) +```sh +# Get gcloud +$ curl https://sdk.cloud.google.com | bash - # get gcloud - curl https://sdk.cloud.google.com | bash - # authorize gcloud and set your default project - gcloud auth login - gcloud config set project $PROJECT_ID +# Authorize gcloud and set your default project +$ gcloud auth login +$ gcloud config set project $PROJECT_ID - # get managed vms component - gcloud components update app-engine-managed-vms +# Get Managed VMs component +$ gcloud components update app-engine-managed-vms - # check that docker is running - boot2docker up +# Check that Docker is running +$ boot2docker up - # run the app locally - gcloud preview app run . - curl -X GET http://localhost:8080 +# Run the app locally +$ gcloud preview app run . +$ curl -X GET http://localhost:8080 - # deploy the app to production - gcloud preview app deploy . - curl -X GET http://$PROJECT_ID.appspot.com +# Deploy the app to production +$ gcloud preview app deploy . +$ curl -X GET http://$PROJECT_ID.appspot.com +``` \ No newline at end of file diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..6d6e922 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,3 @@ +# gcloud-node-todos Examples + +- [Command Line](https://github.com/GoogleCloudPlatform/gcloud-node-todos/apps/cli) diff --git a/apps/cli/cli.js b/apps/cli/cli.js new file mode 100644 index 0000000..6e5b0cc --- /dev/null +++ b/apps/cli/cli.js @@ -0,0 +1,161 @@ +#! /usr/bin/env node + +// +// Copyright 2013 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +'use strict'; + +var todos = require('./'); +var inquirer = require('inquirer'); +var actions = { + add: add, + deleteCompleted: deleteCompleted, + exit: process.kill, + displayTodos: displayTodos, + displayTodosAndDelete: displayTodosAndDelete +}; + +// Start the prompts. +init(); + +function init() { + inquirer.prompt([ + { + message: 'What would you like to do?', + name: 'action', + type: 'list', + choices: [ + { + name: 'List Todos', + value: 'displayTodos' + }, + { + name: 'Add a Todo', + value: 'add' + }, + { + name: 'Delete Todos', + value: 'displayTodosAndDelete' + }, + { + name: 'Delete Completed Todos', + value: 'deleteCompleted' + }, + new inquirer.Separator(), + { + name: 'Exit', + value: 'exit' + } + ] + } + ], function(answers) { + actions[answers.action](answers); + }); +} + +function add() { + inquirer.prompt({ + message: 'What do you need to do?', + name: 'text' + }, function(answers) { + todos.add(answers.text, function(err) { + if (err) { + throw new Error(err); + } + init(); + }); + }); +} + +function displayTodos() { + todos.getTodos(function(err, entities) { + if (err) { + throw new Error(err); + } + if (entities.length === 0) { + console.log('There are no todos!\n'); + init(); + return; + } + inquirer.prompt({ + message: 'What have you completed?', + name: 'completed', + type: 'checkbox', + choices: entities.map(function(entity) { + return { + name: entity.text, + checked: entity.done, + value: entity + }; + }) + }, function(answers) { + // Update entities model. + entities = entities.map(function(entity) { + if (answers.completed.some(function(completed) { + return completed.id === entity.id; + })) { + entity.done = true; + } else { + entity.done = false; + } + return entity; + }); + + todos.update(entities, function(err) { + if (err) { + throw new Error(err); + } + init(); + }); + }); + }); +} + +function displayTodosAndDelete() { + todos.getTodos(function(err, entities) { + if (err) { + throw new Error(err); + } + inquirer.prompt({ + message: 'What would you like to delete?', + name: 'completed', + type: 'checkbox', + choices: entities.map(function(entity) { + return { + name: entity.text, + checked: false, + value: entity + }; + }) + }, function(answers) { + todos.delete(answers.completed, function(err) { + if (err) { + throw new Error(err); + } + init(); + }); + }); + }); +} + +function deleteCompleted() { + todos.deleteCompleted(function(err) { + if (err) { + throw new Error(err); + } + init(); + }); +} \ No newline at end of file diff --git a/apps/cli/index.js b/apps/cli/index.js new file mode 100644 index 0000000..d587e2d --- /dev/null +++ b/apps/cli/index.js @@ -0,0 +1,98 @@ +// +// Copyright 2013 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +'use strict'; + +var request = require('request'); + +// Start server. +require('../../server/server.js'); +var dataset = global.dataset; +var getKeyFromId = global.getKeyFromId; + +function add(text, callback) { + request.post({ + uri: 'http://localhost:8080/todos', + json: { + text: text + } + }, _handleResponse(callback)); +} +module.exports.add = add; + +function getTodos(callback) { + request.get('http://localhost:8080/todos', _handleResponse(callback)); +} +module.exports.getTodos = getTodos; + +function update(todos, callback) { + todos = Array.isArray(todos) ? todos : [todos]; + if (todos.length > 1) { + dataset.runInTransaction(function(transaction, done) { + var keysAndData = todos.map(function(todo) { + return { + key: getKeyFromId(todo.id), + data: { + text: todo.text, + done: todo.done + } + }; + }); + transaction.save(keysAndData, done); + }, callback); + } else if (todos.length === 1) { + request.put({ + uri: 'http://localhost:8080/todos/' + todos[0].id, + json: todos[0] + }, _handleResponse(callback)); + } +} +module.exports.update = update; + +function del(todos, callback) { + todos = Array.isArray(todos) ? todos : [todos]; + if (todos.length > 1) { + dataset.runInTransaction(function(transaction, done) { + var keys = todos.map(function(todo) { + return getKeyFromId(todo.id); + }); + transaction.delete(keys, done); + }, callback); + } else { + request.del('http://localhost:8080/todos/' + todos[0].id, _handleResponse(callback)); + } +} +module.exports.delete = del; + +function deleteCompleted(callback) { + request.del('http://localhost:8080/todos', _handleResponse(callback)); +} +module.exports.deleteCompleted = deleteCompleted; + +function _handleResponse(callback) { + return function(err, res, body) { + if (err) { + callback(err); + return; + } + + try { + body = JSON.parse(body); + } catch (e) {} + + callback(null, body); + }; +} \ No newline at end of file diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..f664c1b --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,13 @@ +{ + "name": "google-cloud-datastore-todos", + "version": "0.0.0", + "private": true, + "bin": { + "datastore-todos": "cli.js" + }, + "dependencies": { + "gcloud-node-todos": "^0.0.1", + "gcloud": "^0.7.1", + "inquirer": "^0.8.0" + } +} diff --git a/apps/cli/readme.md b/apps/cli/readme.md new file mode 100644 index 0000000..11d470e --- /dev/null +++ b/apps/cli/readme.md @@ -0,0 +1,10 @@ +# Command Line Example + +## Running + +```sh +# From the `gcloud-node-todos` root directory: +$ npm link +$ cd apps/cli +$ npm link +$ datastore-todos \ No newline at end of file diff --git a/package.json b/package.json index 154aeb7..50b222e 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,18 @@ "dependencies": { "express": "^4.5.1", "body-parser": "^1.4.3", - "gcloud": "^0.6.0", + "gcloud": "^0.7.1", "markdown": "^0.5.0", "github-markdown-css": "^1.1.1" }, "devDependencies": { "jshint": "^2.5.2", "dredd": "^0.3.9", - "request": "^2.42.0", - "gulp": "^3.8.8" + "request": "^2.42.0" }, "scripts": { - "lint": "jshint *.js", - "test": "gulp test" + "lint": "jshint apps/**/*.js server/**/*.js", + "start": "node ./server/server.js", + "test": "node ./server/test/server_test.js" } } diff --git a/server.js b/server.js deleted file mode 100644 index 65540a3..0000000 --- a/server.js +++ /dev/null @@ -1 +0,0 @@ -require('./todos.js').listen(8080); diff --git a/server/actions.js b/server/actions.js new file mode 100644 index 0000000..3508009 --- /dev/null +++ b/server/actions.js @@ -0,0 +1,134 @@ +'use strict'; + +var gcloud = require('gcloud')({ + projectId: process.env.GAE_LONG_APP_ID || process.env.DATASET_ID, + credentials: require('../key.json') +}); + +var ds = gcloud.datastore.dataset(); +var LIST_NAME = 'default-list'; + +// Make the dataset instance available globally, as well as a helper function to +// retrieve a key. +global.dataset = ds; +global.getKeyFromId = function(id) { + return ds.key(['TodoList', LIST_NAME, 'Todo', id]); +}; + +module.exports = function(app) { + app.get('/todos', getTodos); + app.get('/todos/:id', getTodo); + + app.post('/todos', insertTodo); + app.put('/todos/:id', updateTodo); + + app.delete('/todos', deleteTodos); + app.delete('/todos/:id', deleteTodo); +}; + +function getTodos(req, res) { + var q = ds.createQuery('Todo') + .hasAncestor(ds.key(['TodoList', LIST_NAME])); + ds.runQuery(q, function(err, items) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + res.json(items.map(function(obj) { + obj.data.id = obj.key.path.pop(); + return obj.data; + })); + }); +} + +function getTodo(req, res) { + var id = req.param('id'); + ds.get(ds.key(['TodoList', LIST_NAME, 'Todo', id]), function(err, obj) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + if (!obj) { + return res.status(404).send(); + } + obj.data.id = obj.key.path.pop(); + res.json(obj.data); + }); +} + +function insertTodo(req, res) { + var todo = req.body; + todo.done = false; + ds.save({ + key: ds.key(['TodoList', LIST_NAME, 'Todo']), + data: todo + }, function(err, key) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + todo.id = key.path.pop(); + res.status(201).json(todo); + }); +} + +function updateTodo(req, res) { + var id = req.param('id'); + var todo = req.body; + ds.save({ + key: ds.key(['TodoList', LIST_NAME, 'Todo', id]), + data: todo + }, function(err) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + todo.id = id; + res.json(todo); + }); +} + +function deleteTodo(req, res) { + var id = req.param('id'); + ds.delete(ds.key(['TodoList', LIST_NAME, 'Todo', id]), function(err) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + res.status(204).send(); + }); +} + +function deleteTodos(req, res) { + ds.runInTransaction(function(t, done) { + var q = ds.createQuery('Todo') + .hasAncestor(ds.key(['TodoList', LIST_NAME])) + .filter('done =', true); + t.runQuery(q, function(err, items) { + if (err) { + t.rollback(done); + console.error(err); + res.status(err.code).send(err.message); + return; + } + var keys = items.map(function(obj) { + return obj.key; + }); + t.delete(keys, function(err) { + if (err) { + t.rollback(done); + console.error(err); + res.status(err.code).send(err.message); + return; + } + done(); + res.status(204).send(); + }); + }); + }); +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..9f76252 --- /dev/null +++ b/server/server.js @@ -0,0 +1,38 @@ +'use strict'; + +var bodyParser = require('body-parser'); +var express = require('express'); +var fs = require('fs'); +var markdown = require('markdown').markdown; +var bindTodoRoutes = require('./actions'); + +var css = require.resolve('../node_modules/github-markdown-css/github-markdown.css'); +var apib = require.resolve('./todos.apib'); +var githubMarkdownCSS = fs.readFileSync(css).toString(); +var todosAPIBlueprint = fs.readFileSync(apib).toString(); + +var app = express(); +app.use(bodyParser.json()); + +app.get('/_ah/health', function(req, res) { + res.status(200) + .set('Content-Type', 'text/plain') + .send('ok'); +}); + +app.get('/', function(req, res) { + res.status(200) + .set('Content-Type', 'text/html') + .send([ + '', + ' ', + ' ', + ' ', + ' ' + markdown.toHTML(todosAPIBlueprint) + '', + '' + ].join('\n')); +}); + +bindTodoRoutes(app); + +app.listen(8080); \ No newline at end of file diff --git a/server/test/server_test.js b/server/test/server_test.js new file mode 100644 index 0000000..a2800b9 --- /dev/null +++ b/server/test/server_test.js @@ -0,0 +1,20 @@ +'use strict'; + +// Start server. +require('../server.js'); + +var Dredd = require('dredd'); + +new Dredd({ + blueprintPath: 'server/todos.apib', + server: 'http://localhost:8080', + options: { + hookfiles: 'server/test/test_hooks.js' + } +}) +.run(function(err) { + if (err) { + throw new Error(err); + } + process.exit(); +}); diff --git a/test_hooks.js b/server/test/test_hooks.js similarity index 92% rename from test_hooks.js rename to server/test/test_hooks.js index 525c009..fcd540e 100644 --- a/test_hooks.js +++ b/server/test/test_hooks.js @@ -1,3 +1,5 @@ +'use strict'; + var request = require('request'); // imports the hooks module _injected_ by dredd. var hooks = require('hooks'); @@ -25,8 +27,8 @@ hooks.before('Todos > Todo > Delete a Todo', function(transaction, done) { hooks.after('Todos > Todo > Delete a Todo', function(transaction, done) { request.get({ uri: 'http://localhost:8080' + transaction.fullPath, - }, function(err, res, body) { - console.assert(res.statusCode == 404); + }, function(err, res) { + console.assert(res.statusCode === 404); return done(); }); }); diff --git a/todos.apib b/server/todos.apib similarity index 99% rename from todos.apib rename to server/todos.apib index 35f6324..fef0cee 100644 --- a/todos.apib +++ b/server/todos.apib @@ -31,7 +31,7 @@ Todos API is a todo storage backend for [TodoMVC](//todomvc.com). "text": "do this", "done": false }] - + ## Archive done Todos [DELETE] + Response 204 diff --git a/todos.js b/todos.js deleted file mode 100644 index 8424b24..0000000 --- a/todos.js +++ /dev/null @@ -1,144 +0,0 @@ -var express = require('express'), - bodyParser = require('body-parser'), - fs = require('fs'), - markdown = require('markdown').markdown, - app = express(); - -var gcloud = require('gcloud'), - datastore = gcloud.datastore; - -var ds = new datastore.Dataset({ - projectId: process.env.GAE_LONG_APP_ID || process.env.DATASET_ID, - keyFilename: 'key.json' -}); - -app.use(bodyParser.json()); - -var todoListName = 'default-list'; - -app.get('/todos', function(req, res) { - var q = ds.createQuery('Todo') - .hasAncestor(ds.key('TodoList', todoListName)); - ds.runQuery(q, function(err, items) { - if (err) { - console.error(err); - res.status(500).send(err.message); - return; - } - res.json(items.map(function(obj, i) { - obj.data.id = obj.key.path.pop(); - return obj.data; - })); - }); -}); - -app.get('/todos/:id', function(req, res) { - var id = req.param('id'); - ds.get(ds.key('TodoList', todoListName, 'Todo', id), function(err, obj) { - if (err) { - console.error(err); - res.status(500).send(err.message); - return; - } - if (!obj) { - return res.status(404).send(); - } - obj.data.id = obj.key.path.pop(); - res.json(obj.data); - }); -}); - -app.post('/todos', function(req, res) { - var todo = req.body; - todo.done = false; - ds.save({ - key: ds.key('TodoList', todoListName, 'Todo'), - data: todo - }, function(err, key) { - if (err) { - console.error(err); - res.status(500).send(err.message); - return; - } - todo.id = key.path.pop(); - res.status(201).json(todo); - }); -}); - -app.put('/todos/:id', function(req, res) { - var id = req.param('id'); - var todo = req.body; - ds.save({ - key: ds.key('TodoList', todoListName, 'Todo', id), - data: todo - }, function(err, key) { - if (err) { - console.error(err); - res.status(500).send(err.message); - return; - } - todo.id = id; - res.json(todo); - }); -}); - -app.delete('/todos/:id', function(req, res) { - var id = req.param('id'); - ds.delete(ds.key('TodoList', todoListName, 'Todo', id), function(err) { - if (err) { - console.error(err); - res.status(500).send(err.message); - return; - } - res.status(204).send(); - }); -}); - -app.delete('/todos', function(req, res) { - ds.runInTransaction(function(t, done) { - var q = ds.createQuery('Todo') - .hasAncestor(ds.key('TodoList', todoListName)) - .filter('done =', true); - t.runQuery(q, function(err, items) { - if (err) { - t.rollback(done); - console.error(err); - res.status(500).send(err.message); - return; - } - var keys = items.map(function(obj) { - return obj.key; - }); - t.delete(keys, function(err) { - if (err) { - t.rollback(done); - console.error(err); - res.status(500).send(err.message); - return; - } - done(); - res.status(204).send(); - }); - }); - }); -}); - -app.get('/_ah/health', function(req, res) { - res.status(200) - .set('Content-Type', 'text/plain') - .send('ok'); -}); - -var githubMarkdownCSS = fs.readFileSync('node_modules/github-markdown-css/github-markdown.css').toString(); -var todosAPIBlueprint = fs.readFileSync('todos.apib').toString(); -app.get('/', function(req, res) { - res.status(200) - .set('Content-Type', 'text/html') - .send(''+ - markdown.toHTML(todosAPIBlueprint)+ - ''); -}); - -module.exports = app;