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/.travis.yml b/.travis.yml index 9fd8adc..ca88cf8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,6 @@ node_js: before_install: - openssl aes-256-cbc -K $encrypted_4e543ecb61f7_key -iv $encrypted_4e543ecb61f7_iv -in key.json.enc -out key.json -d before_script: -- npm run-script lint +- gulp lint env: - DATASET_ID=gcloud-todos 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..51d8ec6 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,107 @@ -gcloud-node-todos -================= +# gcloud-node-todos +> [TodoMVC](http://todomvc.com) 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). +## API -# API +#### Insert a todo +```sh +$ curl -X POST -H "Content-Type: application/json" -d '{"text": "do this"}' http://localhost:8080/todos +``` -- Insert a todo +#### Get a todo +```sh +$ curl -X GET http://localhost:8080/todos/{{todo id}} +``` - curl -X POST -d '{text: "do this"}' http://localhost:8080/todos +#### Mark a todo as done +```sh +$ curl -X PUT -H "Content-Type: application/json" -d '{"text": "do this", "done": true}' http://localhost:8080/todos/{{todo id}} +``` -- Get a todo +#### Delete a todo +```sh +$ curl -X DELETE http://localhost:8080/todos/{{todo id}} +``` - curl -X GET http://localhost:8080/todos/{{todo id}} +#### Get all todos +```sh +$ curl -X GET http://localhost:8080/todos +``` -- Mark a todo as done +#### Clear all `done` todos +```sh +$ curl -X DELETE http://localhost:8080/todos +``` - curl -X PUT -d '{text: "do this", "done": true}' http://localhost:8080/todos/{{todo id}} +## Prerequisites -- Delete a todo +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/{{todo id}} +## Running -- Get all todos - - curl -X GET http://localhost:8080/todos +#### Locally +```sh +# Set your default Dataset +$ export DATASET_ID=$PROJECT_ID -- Clear all `done` todos +# Install the dependencies +$ npm install - curl -X DELETE http://localhost:8080/todos +# Start the server +$ npm start -# Prerequisites +# Run acceptance test +$ npm test +``` - - 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= +#### [Docker](https://docker.com) +```sh +# Check that Docker is running +$ boot2docker up +$ export DOCKER_HOST=$(boot2docker shellinit) -# Run locally +# Build your Docker image +$ docker build -t app . - # 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 +# Start a new Docker container +$ docker run -e DATASET_ID=$PROJECT_ID -p 8080:8080 app -# Run in docker +# Test the app +$ curl -X GET http://$(boot2docker ip):8080 +``` - # check that docker is running - boot2docker up - export DOCKER_HOST=$(boot2docker shellinit) +#### [Managed VMs](https://developers.google.com/appengine/docs/managed-vms/) +```sh +# Get gcloud +$ curl https://sdk.cloud.google.com | bash - # build your docker image - docker build -t app . - # start a new docker container - docker run -e DATASET_ID=$PROJECT_ID -p 8080:8080 app +# Authorize gcloud and set your default project +$ gcloud auth login +$ gcloud config set project $PROJECT_ID - # test the app - curl -X GET http://$(boot2docker ip):8080 +# Get Managed VMs component +$ gcloud components update app-engine-managed-vms -# Run w/ [Managed VMs](https://developers.google.com/appengine/docs/managed-vms/) +# Check that Docker is running +$ boot2docker up - # 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 +# Run the app locally +$ gcloud preview app run . +$ curl -X GET http://localhost:8080 - # get managed vms component - gcloud components update app-engine-managed-vms +# Deploy the app to production +$ gcloud preview app deploy . +$ curl -X GET http://$PROJECT_ID.appspot.com +``` - # check that docker is running - boot2docker up +## Other Examples - # 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 +- [Command Line](//github.com/GoogleCloudPlatform/gcloud-node-todos/cli) \ No newline at end of file diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..8ec5c8e --- /dev/null +++ b/cli/README.md @@ -0,0 +1,12 @@ +# Command Line Example + +## Running + +```sh +# From the `gcloud-node-todos` root directory: +$ npm link +$ cd cli +$ npm link gcloud-node-todos +$ npm link +$ datastore-todos +``` \ No newline at end of file diff --git a/cli/cli.js b/cli/cli.js new file mode 100755 index 0000000..bdc91cc --- /dev/null +++ b/cli/cli.js @@ -0,0 +1,176 @@ +#! /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('../todos.js'); + +var inquirer = require('inquirer'); +var actions = { + add: add, + deleteCompleted: deleteCompleted, + displayTodos: displayTodos, + displayTodosAndDelete: displayTodosAndDelete, + exit: process.kill +}; + +// 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.insert({ + text: answers.text + }, function(err) { + if (err) { + throw err; + } + init(); + }); + }); +} + +function displayTodos() { + todos.getAll(function(err, entities) { + if (err) { + throw 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; + }); + + var updated = 0; + entities.forEach(function(entity) { + var id = entity.id; + delete entity.id; + todos.update(id, entity, function(err) { + if (err) { + throw err; + } + if (++updated === entities.length) { + init(); + } + }); + }); + }); + }); +} + +function displayTodosAndDelete() { + todos.getAll(function(err, entities) { + if (err) { + throw 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) { + var deleted = 0; + answers.completed.forEach(function(todo) { + todos.delete(todo.id, function(err) { + if (err) { + throw err; + } + if (++deleted === answers.completed.length) { + init(); + } + }); + }); + }); + }); +} + +function deleteCompleted() { + todos.deleteCompleted(function(err) { + if (err) { + throw err; + } + init(); + }); +} \ No newline at end of file diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..9a73d9e --- /dev/null +++ b/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": "*", + "gcloud": "^0.8.1", + "inquirer": "^0.8.0" + } +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..1c00518 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,34 @@ +'use strict'; + +var gulp = require('gulp'); +var Dredd = require('dredd'); +var jshint = require('gulp-jshint'); +var server = require('./server'); + +gulp.task('dredd', function(cb) { + var test = new Dredd({ + blueprintPath: 'todos.apib', + server: 'http://localhost:8080', + options: { + hookfiles: 'tests/hooks.js' + } + }); + + server.listen(8080, function() { + test.run(function() { + this.close(cb); + }.bind(this)); + }); +}); + +gulp.task('lint', function() { + return gulp.src(['*.js', '*/*.js']) + .pipe(jshint()) + .pipe(jshint.reporter('default')); +}); + +gulp.task('serve', function(cb) { + server.listen(8080, cb); +}); + +gulp.task('test', ['dredd']); \ No newline at end of file diff --git a/package.json b/package.json index 154aeb7..56ef314 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,17 @@ "dependencies": { "express": "^4.5.1", "body-parser": "^1.4.3", - "gcloud": "^0.6.0", + "gcloud": "^0.8.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" + "dredd": "^0.3.12", + "gulp": "^3.8.8", + "gulp-jshint": "^1.8.5" }, "scripts": { - "lint": "jshint *.js", + "start": "gulp serve", "test": "gulp test" } } diff --git a/server.js b/server.js index 65540a3..15fdf0f 100644 --- a/server.js +++ b/server.js @@ -1 +1,73 @@ -require('./todos.js').listen(8080); +'use strict'; + +var bodyParser = require('body-parser'); +var express = require('express'); +var fs = require('fs'); +var markdown = require('markdown').markdown; + +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 todos = require('./todos.js'); + +var app = module.exports = 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')); +}); + +app.get('/todos', function(req, res) { + todos.getAll(_handleApiResponse(res)); +}); + +app.get('/todos/:id', function(req, res) { + todos.get(req.param('id'), _handleApiResponse(res)); +}); + +app.post('/todos', function(req, res) { + todos.insert(req.body, _handleApiResponse(res, 201)); +}); + +app.put('/todos/:id', function(req, res) { + todos.update(req.param('id'), req.body, _handleApiResponse(res)); +}); + +app.delete('/todos', function(req, res) { + todos.deleteCompleted(_handleApiResponse(res, 204)); +}); + +app.delete('/todos/:id', function(req, res) { + todos.delete(req.param('id'), _handleApiResponse(res, 204)); +}); + +function _handleApiResponse(res, successStatus) { + return function(err, payload) { + if (err) { + console.error(err); + res.status(err.code).send(err.message); + return; + } + if (successStatus) { + res.status(successStatus); + } + res.json(payload); + }; +} diff --git a/test_hooks.js b/test_hooks.js deleted file mode 100644 index 525c009..0000000 --- a/test_hooks.js +++ /dev/null @@ -1,43 +0,0 @@ -var request = require('request'); -// imports the hooks module _injected_ by dredd. -var hooks = require('hooks'); - -hooks.before('Todos > Todo > Get a Todo', function(transaction, done) { - request.post({ - uri: 'http://localhost:8080/todos', - json: {'text': 'do that'} - }, function(err, res, todo) { - transaction.fullPath = '/todos/' + todo.id; - return done(); - }); -}); - -hooks.before('Todos > Todo > Delete a Todo', function(transaction, done) { - request.post({ - uri: 'http://localhost:8080/todos', - json: {'text': 'delete me'} - }, function(err, res, todo) { - transaction.fullPath = '/todos/' + todo.id; - return 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); - return done(); - }); -}); - -hooks.after('Todos > Todos Collection > Archive done Todos', function(transaction, done) { - request.get({ - uri: 'http://localhost:8080/todos' - }, function(err, res, body) { - JSON.parse(body).forEach(function(todo) { - console.assert(!todo.done); - }); - return done(); - }); -}); diff --git a/tests/hooks.js b/tests/hooks.js new file mode 100644 index 0000000..aff9260 --- /dev/null +++ b/tests/hooks.js @@ -0,0 +1,54 @@ +'use strict'; + +// imports the hooks module _injected_ by dredd. +var hooks = require('hooks'); +var todos = require('../todos.js'); + +hooks.after('Todos > Todos Collection > Archive done Todos', function(transaction, done) { + todos.getAll(function(err, items) { + items.forEach(function(todo) { + console.assert(!todo.done); + }); + done(); + }); +}); + +hooks.before('Todos > Todo > Get a Todo', function(transaction, done) { + todos.insert({ + text: 'do that' + }, function(err, todo) { + transaction.fullPath = '/todos/' + todo.id; + done(); + }); +}); + +hooks.before('Todos > Todo > Delete a Todo', function(transaction, done) { + todos.insert({ + text: 'delete me' + }, function(err, todo) { + transaction.fullPath = '/todos/' + todo.id; + done(); + }); +}); + +hooks.after('Todos > Todo > Delete a Todo', function(transaction, done) { + var id = transaction.fullPath.split('/')[1]; + todos.get(id, function(err) { + console.assert(err.code === 404); + done(); + }); +}); + +hooks.afterAll(function(done) { + todos.getAll(function(err, items) { + var deleted = 0; + + items.forEach(function(todo) { + todos.delete(todo.id, function() { + if (++deleted === items.length) { + done(); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/todos.apib b/todos.apib index 35f6324..3c640c4 100644 --- a/todos.apib +++ b/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 @@ -57,7 +57,6 @@ Todos API is a todo storage backend for [TodoMVC](//todomvc.com). + Request (application/json) { - "id": 42, "text": "do this", "done": true } diff --git a/todos.js b/todos.js index 8424b24..6d339df 100644 --- a/todos.js +++ b/todos.js @@ -1,144 +1,115 @@ -var express = require('express'), - bodyParser = require('body-parser'), - fs = require('fs'), - markdown = require('markdown').markdown, - app = express(); +'use strict'; -var gcloud = require('gcloud'), - datastore = gcloud.datastore; +var projectId = process.env.GAE_LONG_APP_ID || process.env.DATASET_ID; -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; - })); - }); -}); +if (!projectId) { + var MISSING_ID = [ + 'Cannot find your project ID. Please set an environment variable named ', + '"DATASET_ID", holding the ID of your project.' + ].join(''); + throw new Error(MISSING_ID); +} -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); - }); +var gcloud = require('gcloud')({ + projectId: projectId, + credentials: require('./key.json') }); -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); - }); -}); +var ds = gcloud.datastore.dataset(); +var LIST_NAME = 'default-list'; -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); - }); -}); +function entityToTodo(item) { + var todo = item.data; + todo.id = item.key.path.pop(); + return 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(); - }); -}); +module.exports = { + delete: function(id, callback) { + ds.delete(ds.key(['TodoList', LIST_NAME, 'Todo', id]), function(err) { + callback(err || null); + }); + }, -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) { + deleteCompleted: function(callback) { + ds.runInTransaction(function(transaction, done) { + var q = ds.createQuery('Todo') + .hasAncestor(ds.key(['TodoList', LIST_NAME])) + .filter('done =', true); + transaction.runQuery(q, function(err, items) { if (err) { - t.rollback(done); - console.error(err); - res.status(500).send(err.message); + transaction.rollback(done); return; } - done(); - res.status(204).send(); + var keys = items.map(function(todo) { + return todo.key; + }); + transaction.delete(keys, function(err) { + if (err) { + transaction.rollback(done); + return; + } + done(); + }); }); + }, callback); + }, + + get: function(id, callback) { + ds.get(ds.key(['TodoList', LIST_NAME, 'Todo', id]), function(err, item) { + if (err) { + callback(err); + return; + } + if (!item) { + callback({ + code: 404, + message: 'No matching entity was found.' + }); + return; + } + callback(null, entityToTodo(item)); }); - }); -}); + }, -app.get('/_ah/health', function(req, res) { - res.status(200) - .set('Content-Type', 'text/plain') - .send('ok'); -}); + getAll: function(callback) { + var q = ds.createQuery('Todo') + .hasAncestor(ds.key(['TodoList', LIST_NAME])); + ds.runQuery(q, function(err, items) { + if (err) { + callback(err); + return; + } + callback(null, items.map(entityToTodo)); + }); + }, -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)+ - ''); -}); + insert: function(data, callback) { + data.done = false; + ds.save({ + key: ds.key(['TodoList', LIST_NAME, 'Todo']), + data: data + }, function(err, key) { + if (err) { + callback(err); + return; + } + data.id = key.path.pop(); + callback(null, data); + }); + }, -module.exports = app; + update: function(id, data, callback) { + ds.save({ + key: ds.key(['TodoList', LIST_NAME, 'Todo', id]), + data: data + }, function(err) { + if (err) { + callback(err); + return; + } + data.id = id; + callback(null, data); + }); + } +}; \ No newline at end of file