This commit is contained in:
Christopher Van 2014-08-05 20:35:25 -07:00
Родитель 12265262c2
Коммит 88790e90d2
11 изменённых файлов: 646 добавлений и 16 удалений

28
.gitignore поставляемый
Просмотреть файл

@ -1,23 +1,19 @@
lib-cov
*.seed
*.log
*.csv
*.dat
*.floo*
*.gz
*.log
*.out
*.pid
*.gz
*.floo*
pids
logs
results
*.seed
build
node_modules
settings_local.js
settings_test.js
scripts/*.json
data/
dump.rdb
static/uploads
lib-cov
logs
node_modules
pids
results
scripts/*.json
settings_local.js
settings_test.js

42
README.md Normal file
Просмотреть файл

@ -0,0 +1,42 @@
# galaxy-api
Here lies the REST API for [Galaxy](https://github.com/mozilla/galaxy).
## Installation
To install dependencies:
npm install
Node 0.11.x is required for the `--harmony` flag which enables generators. If you're running an earlier version of Node you may install [n](https://github.com/visionmedia/n), a node version manager to quickly install 0.11.x:
npm install -g n
n 0.11.12
## Development
To run the local web server:
nodemon --harmony bin/api
Alternatively:
npm run-script dev
## Testing
npm test
## Deployment
To run the local web server:
node --harmony bin/api
Alternatively:
npm start

12
api/games/config.json Normal file
Просмотреть файл

@ -0,0 +1,12 @@
{
"name": "games",
"description": "games",
"routes": {
"GET /games": "all",
"POST /games": "create",
"GET /games/:slug": "get",
"PATCH /games/:slug": "update",
"PUT /games/:slug": "replace",
"DELETE /games/:slug": "delete"
}
}

199
api/games/index.js Normal file
Просмотреть файл

@ -0,0 +1,199 @@
/**
* See routes defined in `config.json`.
*/
var Joi = require('joi');
var Promise = require('es6-promise').Promise;
var db = require('../../lib/db');
var utils = require('../../lib/utils');
var games = {}; // Local cache of game data
var redis = db.redis(); // Real database for game data
var validate = utils.promisify(Joi.validate); // Promise-based `Joi.validate`
var gameKeys = {
// App URL must start with `https://` or `http://`.
app_url: Joi.string().regex(/^https?:\/\//).required()
.example('http://nintendo.com/mario-bros/'),
// App Name cannot be longer than 150 characters long.
name: Joi.string().max(150).required()
.example('Mario Bros.'),
// App Slug cannot be all digits, all underscores, or all hyphens
// and must contain only letters, numbers, underscores, and hyphens.
// TODO: Throw an error if `slug` is already taken.
slug: Joi.string().regex(/^(?!\d*$)(?!_*$)(?!-*$)[\w-]+$/).required()
.example('mario-bros')
};
// Define schema for JSON payloads. (Run `Joi.describe` to see examples.)
var gameSchema = Joi.object().keys(gameKeys).example({
app_url: 'http://nintendo.com/mario-bros/',
name: 'Mario Bros.',
slug: 'mario-bros'
});
// For PATCH, use the same schema except every field is optional.
var gameKeysPatch = {};
Object.keys(gameKeys).forEach(function (key) {
gameKeysPatch[key] = gameKeys[key].optional();
});
var gameSchemaPatch = Joi.object().keys(gameKeysPatch);
/**
* GET all games.
*/
exports.all = function *() {
var response = new utils.Response(this);
yield redis.hvals('game')
.then(function (values) {
// `values` is an array of serialised games.
// Create an array of parsed games, and update the local cache.
games = values.map(JSON.parse);
// Return 200 with an array of all the games.
// TODO: Return an object with paginated results and metadata.
response.success(games);
}, response.dbError);
};
/**
* POST a new game.
*/
exports.create = function *() {
var payload = this.request.body;
var response = new utils.Response(this);
yield validate(payload, gameSchema, {abortEarly: false})
.then(function () {
return redis.hset('game', payload.slug, JSON.stringify(payload))
.then(function () {
response.success();
// Add game to local cache.
games[payload.slug] = payload;
},
response.dbError);
},
response.validationError);
};
/**
* GET a single game.
*/
exports.get = function *() {
var response = new utils.Response(this);
var slug = this.params.slug;
var game = yield redis.hget('game', slug).catch(response.dbError);
if (game) {
// Return 200 with game data.
response.success(game);
} else if (game === null) {
// Return 404 if game slug does not exist as a key in the database.
response.missing(game);
}
};
function* edit(self, replace) {
var payload = self.request.body;
var response = new utils.Response(self);
var oldSlug = self.params.slug;
var newSlug = 'slug' in payload ? payload.slug : oldSlug;
var newGameData;
var schema = replace ? gameSchema : gameSchemaPatch;
function editSuccess() {
response.success();
// Update game in local cache.
games[payload.slug] = payload;
}
yield validate(payload, schema, {abortEarly: false})
.then(function () {
return redis.hget('game', oldSlug)
.then(function (gameData) {
if (gameData === null) {
return response.missing();
}
if (replace) {
newGameData = JSON.stringify(payload);
} else {
// Deserialise.
newGameData = JSON.parse(gameData);
// Replace the old keys' values with the new keys' values.
Object.keys(payload).forEach(function (key) {
newGameData[key] = payload[key];
});
// Serialise.
newGameData = JSON.stringify(newGameData);
}
return redis.hset('game', newSlug, newGameData)
.then(function () {
if (newSlug !== oldSlug) {
// Slug was changed, so rename keys.
delete games[oldSlug];
return redis.hdel('game', oldSlug).then(editSuccess);
}
return editSuccess();
});
});
},
response.validationError).catch(response.dbError);
}
/**
* PATCH a single game (change only the fields supplied).
*/
exports.update = function *() {
yield edit(this);
};
/**
* PUT a single game (replace the entire object).
*/
exports.replace = function *() {
yield edit(this, true);
};
/**
* DELETE a single game.
*/
exports.delete = function *() {
var slug = this.params.slug;
if (slug in games) {
delete games[slug];
}
var game = yield redis.hdel('game', slug).catch(response.dbError);
if (game) {
// Return 200 with success.
response.success();
} else if (game === null) {
// Return 404 if game slug does not exist as a key in the database.
response.missing(game);
}
};

75
api/games/test.js Normal file
Просмотреть файл

@ -0,0 +1,75 @@
var request = require('supertest');
var api = require('../..');
describe('GET /games', function () {
it('should respond with games', function (done) {
var app = api();
request(app.listen())
.get('/games')
.expect(200)
.end(done);
});
});
describe('POST /games/', function () {
it('should respond with a success message', function (done) {
var app = api();
request(app.listen())
.post('/games')
.expect(200)
.end(done);
});
});
describe('GET /games/:slug', function () {
it('should respond with a single game', function (done) {
var app = api();
request(app.listen())
.get('/games/mario-bros')
.expect(200)
.end(done);
});
});
describe('PATCH /games/:slug', function () {
it('should respond with a success message', function (done) {
var app = api();
request(app.listen())
.patch('/games/mario-bros')
.expect(200)
.end(done);
});
});
describe('PUT /games/:slug', function () {
it('should respond with a success message', function (done) {
var app = api();
request(app.listen())
.put('/games/mario-bros')
.expect(200)
.end(done);
});
});
describe('DELETE /games/:slug', function () {
it('should respond with a success message', function (done) {
var app = api();
request(app.listen())
.delete('/games/mario-bros')
.expect(200)
.end(done);
});
});

36
bin/api Executable file
Просмотреть файл

@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var program = require('commander');
var api = require('..');
// Options.
program
.option('-H, --host <host>', 'specify the host [$HOST || 0.0.0.0]',
process.env.HOST || '0.0.0.0')
.option('-p, --port <port>', 'specify the port [$PORT || 4000]',
process.env.PORT || '4000')
.option('-b, --backlog <size>', 'specify the backlog size [511]',
process.env.BACKLOG || '511')
.option('-r, --ratelimit <n>', 'ratelimit requests [2500]',
process.env.RATELIMIT || '2500')
.option('-d, --ratelimit-duration <ms>', 'ratelimit duration [1h]',
process.env.RATELIMIT_DURATION || '1h')
.parse(process.argv);
// Create app.
var app = api({
ratelimit: ~~program.ratelimit,
duration: ~~program.ratelimitDuration
});
// Listen.
app.listen(program.port, program.host, ~~program.backlog);
console.log('Listening on %s:%s', program.host, program.port);

70
index.js Normal file
Просмотреть файл

@ -0,0 +1,70 @@
/**
* Module dependencies.
*/
var body = require('koa-parse-json');
var compress = require('koa-compress');
var koa = require('koa');
var logger = require('koa-logger');
var ratelimit = require('koa-ratelimit');
var responseTime = require('koa-response-time');
var router = require('koa-router');
var redis = require('redis');
var load = require('./lib/load');
/**
* Environment.
*/
var env = process.env.NODE_ENV || 'development';
/**
* Expose `api()`.
*/
module.exports = api;
/**
* Initialise an app with the given `opts`.
*
* @param {Object} opts
* @return {Application}
* @api public
*/
function api(opts) {
opts = opts || {};
var app = koa();
// Logging.
if (env !== 'test') {
app.use(logger());
}
// X-Response-Time.
app.use(responseTime());
// Compression.
app.use(compress());
// Rate limiting.
app.use(ratelimit({
max: opts.ratelimit,
duration: opts.duration,
db: redis.createClient()
}));
// Parse JSON bodies.
app.use(body());
// Routing.
app.use(router(app));
// Boot.
load(app, __dirname + '/api');
return app;
}

57
lib/db/index.js Normal file
Просмотреть файл

@ -0,0 +1,57 @@
var urllib = require('url');
var redis = require('romis');
var redisURL = urllib.parse(process.env.REDIS_URL || '');
/**
* Establish connection to Redis database.
*
* You must first define an environment variable, like so:
*
* export REDIS_URL='redis://[db-number[:password]@]host:port'
* export REDIS_URL='redis://[[:password]@]host:port/[db-number]'
*
* All fields after the scheme are optional and will default to
* `localhost` on port `6379`, using database `0`.
*
* This is a good default:
*
* export REDIS_URL='redis://localhost:6379'
*
*
* @api private
*/
module.exports.redis = function () {
var client = redis.createClient(parseInt(redisURL.port || '6379', 10),
redisURL.hostname || 'localhost');
var redisAuth = (redisURL.auth || '').split(':');
var db = redisAuth[0];
var passwd = redisAuth[1];
if (passwd = redisAuth[1]) {
client.auth(passwd, function (err) {
if (err) {
throw err;
}
});
}
if (!db && redisURL.pathname && redisURL.pathname !== '/') {
db = redisURL.pathname.substring(1);
}
if (db) {
client.select(db);
client.on('connect', function () {
redis.send_anyways = true;
redis.select(db);
redis.send_anyways = false;
});
}
return client;
};

58
lib/load/index.js Normal file
Просмотреть файл

@ -0,0 +1,58 @@
/**
* Module dependencies.
*/
var path = require('path');
var fs = require('fs');
var join = path.resolve;
var readdir = fs.readdirSync;
var debug = require('debug')('api');
/**
* Load resources in `root` directory.
*
* @param {Application} app
* @param {String} root
* @api private
*/
module.exports = function (app, root) {
readdir(root).forEach(function (file) {
var dir = join(root, file);
var stats = fs.lstatSync(dir);
if (stats.isDirectory()) {
var conf = require(dir + '/config.json');
conf.name = file;
conf.directory = dir;
if (conf.routes) {
route(app, conf);
}
}
});
};
/**
* Define routes in `conf`.
*/
function route(app, conf) {
debug('routes: %s', conf.name);
var mod = require(conf.directory);
Object.keys(conf.routes).forEach(function (key) {
var prop = conf.routes[key];
var chunks = key.split(' ');
var method = chunks[0];
var path = chunks[1];
debug('%s %s -> .%s', method, path, prop);
var fn = mod[prop];
if (!fn) {
throw new Error(conf.name + ': exports.' + prop + ' is not defined');
}
app[method.toLowerCase()](path, fn);
});
}

45
lib/utils/index.js Normal file
Просмотреть файл

@ -0,0 +1,45 @@
var Promise = require('es6-promise').Promise;
module.exports.promisify = function (func) {
return function () {
var args = Array.prototype.slice.apply(arguments);
return new Promise(function (resolve, reject) {
func.apply({}, args.concat(function (err, value) {
if (err) {
reject(err);
} else {
resolve(value);
}
}));
});
};
};
module.exports.Response = function (self) {
return {
success: function (body) {
// Return 200 with success.
self.status = 200;
self.body = body || {success: true};
},
validationError: function (err) {
// Return 400 for validation error.
self.status = 400;
self.body = err;
},
missing: function () {
// Return 404 if game slug does not exist as a key in the database.
self.status = 404;
self.body = {error: 'not_found'};
},
dbError: function (err) {
console.error('DB error: ' + err);
// Return 500 for database error.
self.status = 500;
self.body = {error: 'db_error'};
}
};
};

40
package.json Normal file
Просмотреть файл

@ -0,0 +1,40 @@
{
"name": "galaxy-api",
"description": "Galaxy API",
"dependencies": {
"commander": "~2.2.0",
"debug": "^1.0.4",
"joi": "^4.6.2",
"koa": "^0.8.2",
"koa-compress": "1.0.7",
"koa-logger": "~1.2.1",
"koa-parse-json": "^1.0.0",
"koa-ratelimit": "~1.0.3",
"koa-response-time": "~1.0.2",
"koa-router": "~3.1.4",
"romis": "^0.0.4",
"es6-promise": "^1.0.0"
},
"devDependencies": {
"chai": "^1.9.1",
"mocha": "^1.21.3",
"should": "^4.0.4",
"supertest": "^0.13.0",
"nodemon": "^1.2.1"
},
"engines": {
"node": ">=0.11.11",
"npm": ">=1.1.x"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git://github.com/mozilla/galaxy-api.git"
},
"scripts": {
"start": "node --harmony ./bin/api",
"dev": "nodemon --harmony ./bin/api",
"test": "NODE_ENV=test ./node_modules/.bin/mocha --require should --reporter spec --harmony --bail api/*/test.js"
},
"version": "0.0.3"
}