diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9239e9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.iml +.idea +node_modules diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..11e560b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + "version": "0.1.0", + // List of configurations. Add new configurations or edit existing ones. + "configurations": [ + { + // Name of configuration; appears in the launch configuration drop down menu. + "name": "Launch Mocha", + // Type of configuration. + "type": "node", + // Workspace relative or absolute path to the program. + "program": "node_modules/mocha/bin/_mocha", + // Automatically stop program after launch. + "stopOnEntry": false, + // Command line arguments passed to the program. + "args": ["--debug-brk", "--timeout", "10000"], + // Workspace relative or absolute path to the working directory of the program being debugged. Default is the current workspace. + "cwd": ".", + // Workspace relative or absolute path to the runtime executable to be used. Default is the runtime executable on the PATH. + "runtimeExecutable": null, + // Optional arguments passed to the runtime executable. + "runtimeArgs": ["--nolazy"], + // Environment variables passed to the program. + "env": { + "NODE_ENV": "development" + }, + // Use JavaScript source maps (if they exist). + "sourceMaps": false, + // If JavaScript source maps are enabled, the generated code is expected in this directory. + "outDir": null + }, + { + "name": "Attach", + "type": "node", + // TCP/IP address. Default is "localhost". + "address": "localhost", + // Port to attach to. + "port": 5858, + "sourceMaps": false + } + ] +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..446753a --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require ('./lib/plugin') \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..56705ea --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs" + } +} \ No newline at end of file diff --git a/lib/adapters/mongodb/converters.js b/lib/adapters/mongodb/converters.js new file mode 100644 index 0000000..535eda2 --- /dev/null +++ b/lib/adapters/mongodb/converters.js @@ -0,0 +1,94 @@ +'use strict' + +const Hapi = require('hapi') +const _ = require('lodash') +const Hoek = require('hoek') +const mongoose = require('mongoose') +const uuid = require('node-uuid') + +module.exports = function() { + const toJsonApi = function (resources) { + if (_.isArray(resources)) { + return _.map(resources, (resource) => { + return toJsonApiSingle(resource); + }) + } else { + return toJsonApiSingle(resources); + } + + function toJsonApiSingle(resource) { + var mapped = _.mapKeys(resource, function (val, key) { + if (key === '_id') return 'id' + else return key + }); + return _.omit(mapped, '__v') + } + } + + const toMongooseModel = function (hhSchema) { + + const mongooseSchema = {} + mongooseSchema._id = { + type: String, + default: () => { + return uuid.v4() + } + } + + var schemaMap = { + 'string': String, + 'number': Number, + 'date': Date, + 'buffer': Buffer, + 'boolean': Boolean, + 'array': Array, + 'any': Object + } + + mongooseSchema.type = 'string' + mongooseSchema.attributes = + _.mapValues(hhSchema.attributes, function (val) { + Hoek.assert(val.isJoi, 'attribute values in the hh schema should be defined with Joi') + return schemaMap[val._type] + }) + + const schema = mongoose.Schema(mongooseSchema) + return mongoose.model(hhSchema.type, schema) + } + + const toMongoosePredicate = function(query) { + const mappedToModel = _.mapKeys(query.filter, function (val, key) { + if (key === 'id') return '_id' + else return `attributes.${key}` + }) + + return _.mapValues(mappedToModel, function (val, key) { + const supportedComparators = ['lt', 'lte', 'gt', 'gte'] + + //if it's a normal value strig, do a $in query + if (_.isString(val) && val.indexOf(',') !== -1) { + return {$in: val.split(',')} + } + + //if it's a comparator, translate to $gt, $lt etc + const valueKey = _.keys(val)[0] + if (_.contains(supportedComparators, valueKey)) { + return {[`$${valueKey}`] : val[valueKey]} + } + + else return val + }) + } + + const toMongooseSort = function(sort) { + if (!sort) return {'_id' : -1} + if(sort.indexOf('-') === 0) { + return {[`attributes.${sort.substr(1)}`] : -1} + } + + return {[`attributes.${sort}`] : 1} + } + + return { toJsonApi, toMongooseModel, toMongoosePredicate, toMongooseSort } +} + diff --git a/lib/adapters/mongodb/index.js b/lib/adapters/mongodb/index.js new file mode 100644 index 0000000..b2c74ba --- /dev/null +++ b/lib/adapters/mongodb/index.js @@ -0,0 +1,118 @@ +'use strict' + +const Hapi = require('hapi') +const Boom = require('boom') +const _ = require('lodash') +const mongoose = require('mongoose') +const converters = require('./converters')() +const utils = require('./utils')() + +mongoose.Promise = require('bluebird') + +module.exports = function (options) { + + const models = {} + + const connect = function(cb) { + mongoose.connect(options.mongodbUrl, cb) + } + + const disconnect = function(cb) { + //clear out events + mongoose.connection._events = {} + mongoose.disconnect(cb) + } + + mongoose.connection.on('error', connect) + + const find = function (type, req) { + const model = models[type] + const query = req.query + const limit = (query.page && query.page.limit) || 1000 + const skip = (query.page && query.page.offset) || 0 + const sort = converters.toMongooseSort(query.sort) + const sparse = query.fields && query.fields[type].split(',') + var predicate = converters.toMongoosePredicate(query) + return model.find(predicate).skip(skip).sort(sort).limit(limit).lean().exec() + .then((resources)=> { + let data = converters.toJsonApi(resources); + if (sparse) { + data = _.map(data, (datum) => { + datum.attributes = _.pick(datum.attributes, sparse) + return datum + }) + } + + return {data} + }) + } + + const findById = function(type, req) { + + const model = models[type] + return model.findById(req.params.id).lean().exec() + .then((resources) => { + if (!resources) { + return Boom.notFound() + } + return {data: converters.toJsonApi(resources)} + }) + } + + const create = function(type, req) { + const model = models[type] + var data = utils.getPayload(req) + return model.create(data) + .then((created) => { + return {data: converters.toJsonApi(created.toObject())} + }) + } + + const update = function(type, req) { + + const model = models[type] + var data = utils.getPayload(req) + return model.findByIdAndUpdate(req.params.id, data) + .then((resource) => { + if (!resource) { + return Boom.notFound() + } + return findById(type, req) + }) + } + + const del = function(type, req) { + const model = models[type] + var predicate = converters.toMongoosePredicate({id: req.params.id}) + return model.remove(predicate) + .then(() => { + return {} + }) + } + + const processSchema = function(hhSchema) { + + if (!models[hhSchema.type]) { + + // clean up existing models and schemas + delete mongoose.models[hhSchema.type] + delete mongoose.modelSchemas[hhSchema.type] + + models[hhSchema.type] = converters.toMongooseModel(hhSchema) + } + return models[hhSchema.type] + } + + return { + connect, + disconnect, + find, + findById, + create, + update, + delete: del, + models, + processSchema + } + +} diff --git a/lib/adapters/mongodb/utils.js b/lib/adapters/mongodb/utils.js new file mode 100644 index 0000000..8d71a4b --- /dev/null +++ b/lib/adapters/mongodb/utils.js @@ -0,0 +1,15 @@ +'use strict' + +const Hapi = require('hapi') +const _ = require('lodash') +const Hoek = require('hoek') +const mongoose = require('mongoose') +const uuid = require('node-uuid') + +module.exports = function() { + const getPayload = function (req) { + return (req.payload) ? req.payload.data : {} + } + + return { getPayload } +} \ No newline at end of file diff --git a/lib/plugin.js b/lib/plugin.js new file mode 100644 index 0000000..9f1077a --- /dev/null +++ b/lib/plugin.js @@ -0,0 +1,88 @@ +'use strict' + +const _ = require('lodash') +const routes = require('./routes')() +const adapterUtils = require('./utils/adapter')() +const routeUtils = require('./utils/route')() + +exports.register = function (server, opts, next) { + server.expose('version', require('../package.json').version); + + const adapter = opts.adapter; + + adapterUtils.checkValidAdapter(adapter); + + adapter.connect(() => { + server.expose('adapter', adapter); + next() + }); + + const get = function (schema) { + routeUtils.createOptionsRoute(server, schema) + adapter.processSchema(schema) + return _.merge(routes.get(schema), { + handler: (req, reply) => { + routeUtils.parseComparators(req) + reply(adapter.find(schema.type, req)) + } + }) + } + + const getById = function (schema) { + routeUtils.createOptionsRoute(server, schema) + adapter.processSchema(schema) + return _.merge(routes.getById(schema), { + handler: (req, reply) => { + reply(adapter.findById(schema.type, req)) + } + }) + } + + const post = function (schema) { + routeUtils.createOptionsRoute(server, schema) + adapter.processSchema(schema) + return _.merge(routes.post(schema), { + handler: (req, reply) => { + reply(adapter.create(schema.type, req)).code(201) + } + }) + } + + const patch = function (schema) { + routeUtils.createOptionsRoute(server, schema) + adapter.processSchema(schema) + return _.merge(routes.patch(schema), { + handler: (req, reply) => { + reply(adapter.update(schema.type, req)) + } + }) + } + + const del = function (schema) { + routeUtils.createOptionsRoute(server, schema) + adapter.processSchema(schema) + return _.merge(routes.delete(schema), { + handler: (req, reply) => { + reply(adapter.delete(schema.type, req)).code(204) + } + }) + } + + server.expose('routes', { + get: get, + getById: getById, + post: post, + patch: patch, + delete: del + }) + + server.ext('onPostStop', (server, next) => { + adapter.disconnect(next) + }) +} + +exports.register.attributes = { + pkg: require('../package.json') +} + +exports.getAdapter = adapterUtils.getStandardAdapter; diff --git a/lib/routes.js b/lib/routes.js new file mode 100644 index 0000000..3ea85f2 --- /dev/null +++ b/lib/routes.js @@ -0,0 +1,81 @@ +'use strict' + +const schemaUtils = require('./utils/schema')() + +module.exports = function () { + + const get = function (schema) { + return { + method: 'GET', + path: `/${schema.type}`, + config: { + validate: { + query: schemaUtils.toJoiGetQueryValidation(schema) + } + } + } + } + + const getById = function (schema) { + return { + method: 'GET', + path: `/${schema.type}/{id}`, + config: { + validate: { + query: false + } + } + } + } + + const post = function (schema) { + return { + method: 'POST', + path: `/${schema.type}`, + config: { + payload: { + allow: ['application/json', 'application/vnd.api+json'] + }, + validate: { + payload: schemaUtils.toJoiPostValidatation(schema) + } + } + } + } + + const patch = function (schema) { + return { + method: 'PATCH', + path: `/${schema.type}/{id}`, + config: { + payload: { + allow : 'application/json' + } + } + } + } + + const del = function (schema) { + return { + method: 'DELETE', + path: `/${schema.type}/{id}`, + } + } + + const options = function (schema) { + return { + method: 'OPTIONS', + path: `/${schema.type}` + } + } + + return { + get: get, + getById, + post, + patch, + delete: del, + options + } + +} \ No newline at end of file diff --git a/lib/utils/adapter.js b/lib/utils/adapter.js new file mode 100644 index 0000000..cad268d --- /dev/null +++ b/lib/utils/adapter.js @@ -0,0 +1,30 @@ +'use strict' + +const _ = require('lodash') +const Hoek = require('hoek') +const protocolFunctions = ['connect', 'disconnect', 'find', 'findById', 'create', 'delete', 'models', 'processSchema']; + +module.exports = function() { + const checkValidAdapter = function(adapter) { + + Hoek.assert(adapter, new Error('No adapter passed. Please see docs.')) + + protocolFunctions.forEach((func) => { + Hoek.assert(adapter[func], new Error('Adapter validation failed. Adapter missing ' + func)); + }) + } + + const getStandardAdapter = function(adapter) { + if ( _.isString(adapter)) { + try { + return require('../../lib/adapters/' + adapter); + } catch (err) { + Hoek.assert(!err, new Error('Wrong adapter name, see docs for built in adapter')) + } + } + + return adapter; + } + + return { checkValidAdapter, getStandardAdapter } +} \ No newline at end of file diff --git a/lib/utils/route.js b/lib/utils/route.js new file mode 100644 index 0000000..943af42 --- /dev/null +++ b/lib/utils/route.js @@ -0,0 +1,47 @@ +'use strict' + +const _ = require('lodash') +const routes = require('../routes')() + +module.exports = function() { + const createOptionsRoute = function(server, schema) { + const tables = _.map(server.table()[0].table) + + //see if the options method already exists, if so, don't duplicate it + if (_.find(tables, {path : '/' + schema.type, method: 'options'})) return; + + server.route(_.merge(routes.options(schema), { + handler: (req, reply) => { + const tables = _.map(req.server.table()[0].table, (table) => { + return _.pick(table, 'path', 'method') + }) + + const pathVerbs = _.chain(tables) + .filter((table) => { + return table.path.replace('/{id}', '') === req.path + }) + .pluck('method') + .map((verb) => { return verb.toUpperCase() }) + .value(); + + reply().header('Allow', pathVerbs.join(',')) + } + })) + } + + const parseComparators = function(req) { + const supportedComparators = ['lt', 'lte', 'gt', 'gte'] + + req.query.filter && _.each(req.query.filter, (filter, key) => { + const split = filter.split('=') + + if (split.length > 1 && _.contains(supportedComparators, split[0])) { + req.query.filter[key] = {[split[0]] : split[1]} + } + }) + + return req + } + + return { createOptionsRoute, parseComparators } +} \ No newline at end of file diff --git a/lib/utils/schema.js b/lib/utils/schema.js new file mode 100644 index 0000000..022ba68 --- /dev/null +++ b/lib/utils/schema.js @@ -0,0 +1,49 @@ +'use strict' + +const _ = require('lodash') +const Joi = require('joi') +const Hoek = require('hoek') + +module.exports = function() { + const toJoiPostValidatation = function(schema) { + return Joi.object().keys({ + data: Joi.object().keys({ + id: Joi.string().regex(/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}/), + type: Joi.string().regex(new RegExp(schema.type)).required(), + attributes: schema.attributes, + //TODO needs more granular validation once these are implemented + relationships: Joi.object(), + links: Joi.object(), + meta: Joi.object(), + }) + }) + } + + const toJoiGetQueryValidation = function(schema) { + const keys = _.keys(schema.attributes); + const join = keys.join('|') + + const regex = new RegExp('^(' + join + ')(,(' + join + '))*$', 'i') + const include = Joi.string().regex(regex) + + let filterMap = {} + keys.forEach((key) => { + filterMap[key] = Joi.string() + }) + + const filter = Joi.object(filterMap) + const fields = Joi.object({[schema.type] : Joi.string()}) + + const sortRegex = new RegExp('^-?(' + join + ')(,-?(' + join + '))*$', 'i') + const sort = Joi.string().regex(sortRegex) + + const page = Joi.object({ + limit: Joi.number(), + offset: Joi.number() + }) + + return {include, fields, sort, page, filter} + } + + return { toJoiPostValidatation, toJoiGetQueryValidation } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..365bf52 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "harvester", + "version": "0.1.0", + "description": "Harvester Hapi Plugin", + "main": "index.js", + "scripts": { + "test": "mocha test" + }, + "repository": { + "type": "git", + "url": "https://github.com/agco/hapi-harvester.git" + }, + "keywords": [ + "hapi", + "api", + "harvester" + ], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/agco/hapi-harvester/issues" + }, + "homepage": "https://github.com/agco/hapi-harvester", + "dependencies": { + "bluebird": "^2.10.2", + "boom": "^2.9.0", + "hapi": "^10.4.1", + "hoek": "^2.16.3", + "joi": "^6.9.0", + "lodash": "^3.10.1", + "mongoose": "^4.1.11", + "node-uuid": "^1.4.3" + }, + "devDependencies": { + "chai": "^3.3.0", + "chai-things": "^0.2.0", + "inject-then": "^2.0.2", + "mocha": "^2.3.3" + } +} diff --git a/test/adapter.spec.js b/test/adapter.spec.js new file mode 100644 index 0000000..5d36464 --- /dev/null +++ b/test/adapter.spec.js @@ -0,0 +1,68 @@ +'use strict' + +const _ = require('lodash') +const Joi = require('joi') +const Promise = require('bluebird') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string() + } +}; + +describe('Adapter Validation', function() { + + afterEach(function(done) { + destroyServer(done); + }) + + it('Will check the given adapter for the required functions', function() { + let adapter = require('../').getAdapter('mongodb') + + adapter = _.remove(adapter, 'delete'); + + //rebuild server with the aling adapter + server = new Hapi.Server() + server.connection({port : 9100}) + + const serverSetup = function() { + server.register([ + {register: require('../lib/plugin'), options: {adapter : adapter}}, + {register: require('inject-then')} + ], () => { + hh = server.plugins.harvester; + server.start(()=> {}) + }) + } + + expect(serverSetup).to.throw('Adapter validation failed. Adapter missing connect') + }) + + it('Will won\'t accept a string adapter if it doesn\'t exist ', function() { + //rebuild server with the aling adapter + server = new Hapi.Server() + server.connection({port : 9100}) + + const serverSetup = function() { + const adapter = require('../').getAdapter('nonexistant') + server.register([ + {register: require('../lib/plugin'), options: {adapter : adapter}}, + {register: require('inject-then')} + ], () => { + hh = server.plugins.harvester; + server.start(()=> {}) + }) + } + + expect(serverSetup).to.throw('Wrong adapter name, see docs for built in adapter') + }) +}) + +destroyServer = function(done) { + server.stop(done) +} \ No newline at end of file diff --git a/test/filter.spec.js b/test/filter.spec.js new file mode 100644 index 0000000..eb768df --- /dev/null +++ b/test/filter.spec.js @@ -0,0 +1,221 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + year: Joi.number(), + series: Joi.number(), + description: Joi.string() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + year: 2007, + series: 5, + description: 'Massey Furgeson' + } +}; + + +//TODO just done the validation, actual includes is remaining +describe('Filtering', function() { + + beforeEach(function(done) { + buildServer(() => { + let promises = []; + + _.times(10, (index) => { + let payload = Object.assign({}, data) + payload.attributes.year = 2000 + index; + payload.attributes.series = 0 + index; + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data : payload}})) + }) + + return Promise.all(promises) + .then(() => { + done() + }) + }) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('Will be able to GET all from /brands with a equal filtering param', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=2007'}) + .then((res) => { + expect(res.result.data).to.have.length(1) + expect(res.result.data[0].attributes).to.deep.equal({ + code: 'MF', + year: 2007, + series: 7, + description: 'Massey Furgeson' + }) + }) + }) + + it('Will be able to GET all from /brands with a "greater than" comparator filtering param', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=gt=2005'}) + .then((res) => { + expect(res.result.data).to.have.length(4) + + var expectedResponses = _.times(4, (index) => { + return { + code: 'MF', + year: 2006 + index, + series: 6 + index, + description: 'Massey Furgeson' + } + }) + + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(expectedResponses).to.include.something.that.deep.equals(data.attributes) + }) + }) + }) + + it('Will be able to GET all from /brands with a "greater than equal" comparator filtering param', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=gte=2005'}) + .then((res) => { + expect(res.result.data).to.have.length(5) + + var expectedResponses = _.times(5, (index) => { + return { + code: 'MF', + year: 2005 + index, + series: 5 + index, + description: 'Massey Furgeson' + } + }) + + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(expectedResponses).to.include.something.that.deep.equals(data.attributes) + }) + }) + }) + + it('Will be able to GET all from /brands with a "less than" comparator filtering param', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=lt=2005'}) + .then((res) => { + expect(res.result.data).to.have.length(5) + + var expectedResponses = _.times(5, (index) => { + return { + code: 'MF', + year: 2004 - index, + series: 4 - index, + description: 'Massey Furgeson' + } + }) + + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(expectedResponses).to.include.something.that.deep.equals(data.attributes) + }) + }) + }) + + it('Will be able to GET all from /brands with a "less than equal" comparator filtering param', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=lte=2005'}) + .then((res) => { + expect(res.result.data).to.have.length(6) + + var expectedResponses = _.times(6, (index) => { + return { + code: 'MF', + year: 2005 - index, + series: 5 - index, + description: 'Massey Furgeson' + } + }) + + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(expectedResponses).to.include.something.that.deep.equals(data.attributes) + }) + }) + }) + + it('Will be able to GET all from /brands with a combination of comparator filtering params', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=lte=2005&filter[series]=gte=3'}) + .then((res) => { + expect(res.result.data).to.have.length(3) + + var expectedResponses = _.times(3, (index) => { + return { + code: 'MF', + year: 2005 - index, + series: 5 - index, + description: 'Massey Furgeson' + } + }) + + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(expectedResponses).to.include.something.that.deep.equals(data.attributes) + }) + }) + }) + + it('Will be able to GET all from /brands with a combination of comparator and equal filtering params', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=lte=2005&filter[series]=3'}) + .then((res) => { + expect(res.result.data).to.have.length(1) + + expect(res.result.data[0].attributes).to.deep.equal({ + code: 'MF', + year: 2003, + series: 3, + description: 'Massey Furgeson' + }) + }) + }) + + it('Will be able to GET all from /brands with multiple filtering params', function() { + return server.injectThen({method: 'get', url: '/brands?filter[year]=lt=2010&filter[series]=gt=2'}) + .then((res) => { + res.result.data.forEach((data) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data).to.deep.equal(data) + }) + }) + }) + + it('Won\'t be able to GET all from /brands with multiple filtering params where one is not available in attributes', function() { + + return server.injectThen({method: 'get', url: '/brands?filter[foo]=ge=2007&filter[year]=gt=2000'}) + .then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file diff --git a/test/global.spec.js b/test/global.spec.js new file mode 100644 index 0000000..ae9dbe9 --- /dev/null +++ b/test/global.spec.js @@ -0,0 +1,46 @@ +'use strict' + +const chai = require('chai') +const _ = require('lodash') +const Promise = require('bluebird') + +chai.use(require('chai-things')) + +chai.config.includeStack = true + +global.expect = chai.expect +global.AssertionError = chai.AssertionError +global.Assertion = chai.Assertion +global.assert = chai.assert +global.utils = { + getData: (res) => { + const data = res.result.data; + return _.omit(data, 'id') + }, + removeFromDB: (server, collection) => { + const model = server.plugins.harvester.adapter.models['brands'] + return model.remove({}).lean().exec() + }, + buildServer: (schema) => { + let server, hh; + const Hapi = require('hapi') + const plugin = require('../') + const adapter = plugin.getAdapter('mongodb') + server = new Hapi.Server() + server.connection({port : 9100}) + return new Promise((resolve) => { + server.register([ + {register: require('../'), options: {adapter: adapter({mongodbUrl: 'mongodb://localhost/test'})}}, + {register: require('inject-then')} + ], () => { + hh = server.plugins.harvester; + server.start(() => { + ['get', 'getById', 'post', 'patch', 'delete'].forEach(function(verb) { + server.route(hh.routes[verb](schema)) + }) + resolve({server, hh}) + }) + }) + }) + } +} \ No newline at end of file diff --git a/test/includes.spec.js b/test/includes.spec.js new file mode 100644 index 0000000..a8e3bd4 --- /dev/null +++ b/test/includes.spec.js @@ -0,0 +1,95 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string(), + year: Joi.number() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson', + year: 2007 + } +}; + + +//TODO just done the validation, actual includes is remaining +describe.skip('Inclusion', function() { + + beforeEach(function(done) { + buildServer(() => { + let promises = []; + + _.times(10, () => { + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data}})) + }) + + return Promise.all(promises) + .then(() => { + done() + }) + }) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('Will be able to GET all from /brands with a inclusion', function() { + return server.injectThen({method: 'get', url: '/brands?include=code'}) + .then((res) => { + res.result.data.forEach((result) => { + let dataToCompare = _.pick(data.attributes, 'code') + expect(result.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(result.attributes).to.deep.equal(dataToCompare) + }) + }) + }) + + it('Will be able to GET all from /brands with multiple inclusions', function() { + return server.injectThen({method: 'get', url: '/brands?include=code,description'}) + .then((res) => { + res.result.data.forEach((result) => { + let dataToCompare = _.pick(data.attributes, ['code', 'description']) + expect(result.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(result.attributes).to.deep.equal(dataToCompare) + }) + }) + }) + + it('Won\'t be able to GET all from /brands with an inclusion not available in attributes', function() { + return server.injectThen({method: 'get', url: '/brands?include=code,foo'}) + .then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file diff --git a/test/page.spec.js b/test/page.spec.js new file mode 100644 index 0000000..a6127a8 --- /dev/null +++ b/test/page.spec.js @@ -0,0 +1,96 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string(), + year: Joi.number() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson', + year: 2000 + } +}; + +//TODO just done the validation, actual includes is remaining +describe('Paging', function() { + + beforeEach(function(done) { + buildServer(() => { + let promises = []; + + _.times(50, (index) => { + let payload = Object.assign({}, data) + payload.attributes.year = 2000 + index; + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data: payload}})) + }) + + return Promise.all(promises) + .then(() => { + done() + }) + }) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('Will be able to GET all from /brands with a paging param', function() { + return server.injectThen({method: 'get', url: '/brands?sort=year&page[limit]=10'}) + .then((res) => { + expect(res.result.data).to.have.length(10) + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data.attributes.year).to.equal(2000 + index) + }) + }) + }) + + it('Will be able to GET all from /brands with multiple paging params', function() { + return server.injectThen({method: 'get', url: '/brands?sort=year&page[offset]=20&page[limit]=10'}) + .then((res) => { + res.result.data.forEach((data, index) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data.attributes.year).to.equal(2020 + index) + }) + }) + }) + + it('Won\'t be able to GET all from /brands with multiple paging params where one is not available in attributes', function() { + + return server.injectThen({method: 'get', url: '/brands?page[foo]=bar&page[limit]=100'}) + .then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file diff --git a/test/plugin.spec.js b/test/plugin.spec.js new file mode 100644 index 0000000..f6f5670 --- /dev/null +++ b/test/plugin.spec.js @@ -0,0 +1,74 @@ +'use strict' + +const Joi = require('joi') +const Promise = require('bluebird') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson' + } +}; + +describe('Plugin Basics', function() { + beforeEach(function(done) { + buildServer(done); + }) + + afterEach(function(done) { + destroyServer(done); + }) + + it('Attaches the plugin to Hapi server configuration', function() { + expect(server.plugins.harvester.version).to.equal('0.1.0') + }) + + it('should have the injectThen method available', function() { + return server.injectThen({method: 'GET', url: '/chuck'}) + .then((res) => { + expect(res.result).to.deep.equal({ statusCode: 404, error: 'Not Found' }) + }) + }) + + it('only sends the available verbs on OPTIONS call', function() { + + ['get', 'post', 'patch', 'delete'].forEach(function(verb) { + server.route(hh.routes[verb](schema)) + }) + + return server.injectThen({method: 'OPTIONS', url: '/brands'}) + .then(function(res) { + expect(res.headers.allow).to.equal('OPTIONS,GET,POST,PATCH,DELETE') + }) + }) +}) + +buildServer = function(done) { + const Hapi = require('hapi') + const plugin = require('../') + const adapter = plugin.getAdapter('mongodb') + server = new Hapi.Server() + server.connection({port : 9100}) + server.register([ + {register: require('../'), options: {adapter: adapter({mongodbUrl: 'mongodb://localhost/test'})}}, + {register: require('inject-then')} + ], function() { + hh = server.plugins.harvester; + server.start(done) + }) +} + +destroyServer = function(done) { + server.stop(done) +} \ No newline at end of file diff --git a/test/rest.spec.js b/test/rest.spec.js new file mode 100644 index 0000000..633e1dd --- /dev/null +++ b/test/rest.spec.js @@ -0,0 +1,246 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') +const uuid = require('node-uuid') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson' + } +}; + +describe('Rest operations when things go right', function() { + + beforeEach(function(done) { + buildServer(done) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('should set the content-type header to application/json by default', function() { + return server.injectThen({method: 'GET', url: '/brands'}) + .then((res) => { + expect(res.headers['content-type']).to.equal('application/json; charset=utf-8') + }) + }) + + it('should allow all request with content-type set to application/json', function() { + const headers = { + 'content-type' : 'application/json' + } + + server.injectThen({method: 'post', url: '/brands', headers: headers, payload: {data}}) + .then((res) => { + expect(res.statusCode).to.equal(201) + }) + }) + + it('should allow all request with content-type set to application/vnd.api+json', function() { + const headers = { + 'content-type' : 'application/vnd.api+json' + } + + server.injectThen({method: 'post', url: '/brands', headers: headers, payload: {data}}) + + .then((res) => { + expect(res.statusCode).to.equal(201) + }) + }) + + it('Will be able to GET by id from /brands', function() { + return server.injectThen({method: 'post', url: '/brands', payload: {data}}) + .then((res) => { + return server.injectThen({method: 'get', url: '/brands/' + res.result.data.id}) + }) + .then((res) => { + expect(res.result.data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(utils.getData(res)).to.deep.equal(data) + }) + }) + + it('Will be able to GET all from /brands', function() { + let promises = []; + + _.times(10, () => { + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data}})) + }) + + return Promise.all(promises) + .then((res) => { + return server.injectThen({method: 'get', url: '/brands'}) + }) + .then((res) => { + res.result.data.forEach((data) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data).to.deep.equal(data) + }) + }) + }) + + it('Will be able to POST to /brands', function() { + let payload = _.cloneDeep(data) + payload.id = uuid.v4() + + return server.injectThen({method: 'post', url: '/brands', payload: {data}}).then((res) => { + expect(res.result.data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(utils.getData(res)).to.deep.equal(data) + }) + }) + + it('Will be able to POST to /brands with uuid', function() { + return server.injectThen({method: 'post', url: '/brands', payload: {data}}).then((res) => { + expect(res.result.data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(utils.getData(res)).to.deep.equal(data) + }) + }) + + it('Will be able to PATCH in /brands', function() { + const payload = { + type: 'brands', + attributes: { + code: 'VT', + description: 'Valtra' + } + }; + return server.injectThen({method: 'post', url: '/brands', payload: {data}}) + .then((res) => { + return server.injectThen({method: 'patch', url: '/brands/' + res.result.data.id, payload: {data : payload}}) + }) + .then((res) => { + expect(res.result.data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(utils.getData(res)).to.deep.equal(payload) + }) + }) + + it('Will be able to DELETE in /brands', function() { + return server.injectThen({method: 'post', url: '/brands', payload: {data}}) + .then((res) => { + return server.injectThen({method: 'delete', url: '/brands/' + res.result.data.id}) + }) + .then((res) => { + expect(res.statusCode).to.equal(204) + }) + }) +}) + +describe('Rest operations when things go wrong', function() { + + beforeEach(function(done) { + buildServer(done) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('should reject all request with content-type not set to application/json or application/vnd.api+json', function() { + + const headers = { + 'content-type' : 'text/html' + } + + return server.injectThen({method: 'post', url: '/brands', headers : headers}).then((res) => { + expect(res.statusCode).to.equal(415) + }) + }) + + it('Won\'t be able to POST to /brands with a payload that doesn\'t match the schema', function() { + + let payload = _.cloneDeep(data); + payload.foo = 'bar' + + return server.injectThen({method: 'post', url: '/brands', payload: {data: payload}}).then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) + + it('Won\'t be able to POST to /brands with a payload that doesn\'t have a type property', function() { + + let payload = _.cloneDeep(data); + delete payload.type + + return server.injectThen({method: 'post', url: '/brands', payload: {data: payload}}).then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) + + it('Won\'t be able to POST to /brands with an invalid uuid', function() { + + let payload = _.cloneDeep(data); + // has to match this /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12} + payload.id = '54ce70cd-9d0e-98e8-89c2-1423affcb0ca' + + return server.injectThen({method: 'post', url: '/brands', payload: {data: payload}}).then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) + + it('Won\'t be able to POST to /brands with a payload that has attributes that don\'t match the schema', function() { + + let payload = _.cloneDeep(data); + payload.attributes.foo = 'bar' + + return server.injectThen({method: 'post', url: '/brands', payload: {data: payload}}).then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) + + it('Won\'t be able to GET by id from /brands if id is wrong', function() { + return server.injectThen({method: 'post', url: '/brands', payload: {data}}) + .then((res) => { + return server.injectThen({method: 'get', url: '/brands/foo'}) + }) + .then((res) => { + expect(res.statusCode).to.equal(404) + }) + }) + + it('Will be able to PATCH in /brands with wrong id', function() { + const payload = { + attributes: { + code: 'VT', + description: 'Valtra' + } + }; + return server.injectThen({method: 'post', url: '/brands', payload: {data}}) + .then((res) => { + return server.injectThen({method: 'patch', url: '/brands/foo', payload: {data : payload}}) + }) + .then((res) => { + expect(res.statusCode).to.equal(404) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file diff --git a/test/sort.spec.js b/test/sort.spec.js new file mode 100644 index 0000000..8866c56 --- /dev/null +++ b/test/sort.spec.js @@ -0,0 +1,90 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string(), + year: Joi.number() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson', + year: 2000 + } +}; + +describe('Sorting', function() { + + beforeEach(function(done) { + buildServer(() => { + let promises = []; + + _.times(10, (index) => { + let payload = Object.assign({}, data) + payload.attributes.year = 2000 + index; + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data: payload}})) + }) + + return Promise.all(promises) + .then(() => { + done() + }) + }) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('Will be able to GET all from /brands with a sort param', function() { + return server.injectThen({method: 'get', url: '/brands?sort=year'}) + .then((res) => { + var sortedResults = _.sortBy(res.result.data, 'attributes.year') + expect(sortedResults).to.deep.equal(res.result.data) + }) + }) + + it('Will be able to GET all from /brands with a sort param and descending', function() { + return server.injectThen({method: 'get', url: '/brands?sort=-year'}) + .then((res) => { + + var sortedResults = _.sortBy(res.result.data, 'attributes.year').reverse() + expect(sortedResults).to.deep.equal(res.result.data) + }) + }) + + it('Won\'t be able to GET all from /brands with an sort param not available in attributes', function() { + return server.injectThen({method: 'get', url: '/brands?sort=code,foo'}) + .then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file diff --git a/test/sparse.fieldsets.spec.js b/test/sparse.fieldsets.spec.js new file mode 100644 index 0000000..bce0427 --- /dev/null +++ b/test/sparse.fieldsets.spec.js @@ -0,0 +1,98 @@ +'use strict' + +const _ = require('lodash') +const Promise = require('bluebird') +const Joi = require('joi') +const Hapi = require('hapi') + +let server, buildServer, destroyServer, hh; + +const schema = { + type: 'brands', + attributes: { + code: Joi.string().min(2).max(10), + description: Joi.string(), + year: Joi.number() + } +}; + +const data = { + type: 'brands', + attributes: { + code: 'MF', + description: 'Massey Furgeson', + year: 2000 + } +}; + + +//TODO just done the validation, actual includes is remaining +describe('Sparse Fieldsets', function() { + + beforeEach(function(done) { + buildServer(() => { + let promises = []; + + _.times(10, () => { + promises.push(server.injectThen({method: 'post', url: '/brands', payload: {data}})) + }) + + return Promise.all(promises) + .then(() => { + done() + }) + }) + }) + + afterEach(function(done) { + destroyServer(done) + }) + + it('Will be able to GET all from /brands with a sparse fieldset', function() { + + return server.injectThen({method: 'get', url: '/brands?fields[brands]=description'}) + .then((res) => { + res.result.data.forEach((data) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data.attributes.description).to.exist; + expect(data.attributes.code).to.not.exist; + expect(data.attributes.year).to.not.exist; + }) + }) + }) + + it('Will be able to GET all from /brands with multiple fieldset', function() { + + return server.injectThen({method: 'get', url: '/brands?fields[brands]=code,description'}) + .then((res) => { + res.result.data.forEach((data) => { + expect(data.id).to.match(/[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/) + expect(data).to.deep.equal(data) + }) + }) + }) + + it('Won\'t be able to GET all from /brands with multiple fieldset where one is not available in attributes', function() { + + return server.injectThen({method: 'get', url: '/brands?fields[foo]=bar&fields[description]=Massey Furgeson'}) + .then((res) => { + expect(res.statusCode).to.equal(400) + }) + }) +}) + +buildServer = function(done) { + return utils.buildServer(schema) + .then((res) => { + server = res.server; + hh = res.hh; + done() + }) +} + +destroyServer = function(done) { + utils.removeFromDB(server, 'brands') + .then((res) => { + server.stop(done) + }) +} \ No newline at end of file