├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── docker-compose.test.yml ├── examples ├── README.md ├── artists_insertAndStream.js ├── docker-compose.yml ├── package.json └── replSet.sh ├── lib ├── adapter.js ├── adapters │ └── mongodb.js ├── events-reader-checkpoint-writer.js ├── events-reader.js ├── harvester.js ├── includes.js ├── jsonapi-error.js ├── route.js ├── route.method.js ├── send-error.js ├── sse.js └── validation.js ├── package-lock.json ├── package.json └── test ├── app.js ├── associations.spec.js ├── authorization.spec.js ├── bodyParserConfiguration.spec.js ├── chaining.spec.js ├── checkpoint-writer.spec.js ├── config.js ├── customHarvesterInstance.spec.js ├── deletes.spec.js ├── events-reader.spec.js ├── exportPermissions.spec.js ├── filters.spec.js ├── fixtures ├── collars.js ├── foobars.js ├── immutables.js ├── index.js ├── people.js ├── pets.js └── readers.js ├── global.spec.js ├── immutable.spec.js ├── includes.spec.js ├── jsonapi_error.spec.js ├── limits.spec.js ├── mocha.opts ├── multiSSE.spec.js ├── paging.spec.js ├── readOnly.spec.js ├── remoteIncludes.spec.js ├── resources.spec.js ├── restricted.spec.js ├── roles.spec.js ├── seeder.js ├── send-error.spec.js ├── singleRouteSSE.spec.js ├── sorting.spec.js └── validation.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | end_of_line = lf 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | insert_final_newline = false 17 | 18 | [*.yml] 19 | insert_final_newline = false 20 | 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["node"], 3 | "extends": ["eslint:recommended", "plugin:node/recommended"], 4 | "rules": { 5 | "node/exports-style": ["error", "module.exports"], 6 | "semi": 2, 7 | "indent": ["error", 2], 8 | "no-console": 0, 9 | "node/no-unpublished-require": ["error", { 10 | "allowModules": [ 11 | "supertest", 12 | "should", 13 | "chai", 14 | "chai-http", 15 | "agco-event-source-stream", 16 | "before", 17 | "sinon", 18 | "nock", 19 | "request-debug", 20 | "profanity-util" 21 | ], 22 | "tryExtensions": [".js", ".json", ".node"] 23 | }] 24 | }, 25 | "globals": { 26 | "describe": true, 27 | "before": true, 28 | "it": true, 29 | "expect": true, 30 | "beforeEach": true, 31 | "afterEach": true, 32 | "supertest": true, 33 | "context": true, 34 | "chai": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | data/ 3 | *.log 4 | .idea 5 | *.iml 6 | *.DS_Store 7 | .c9/ 8 | coverage/ 9 | db/ 10 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "unused": true, 4 | "es5": true, 5 | "laxcomma": true 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | data/ 3 | *.log 4 | .idea 5 | *.iml 6 | *.DS_Store 7 | .c9/ 8 | coverage/ 9 | db/ 10 | .editorconfig 11 | .eslintrc.json 12 | .jshintrc 13 | .travis.yml 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8.4.0' 4 | services: 5 | - docker 6 | before_script: 7 | - docker-compose -f docker-compose.test.yml up -d db 8 | - docker-compose -f docker-compose.test.yml up initReplicaset 9 | - docker-compose -f docker-compose.test.yml build test 10 | script: docker-compose -f docker-compose.test.yml run test 11 | deploy: 12 | provider: npm 13 | email: developer@agcocorp.com 14 | api_key: 15 | secure: "ZOFUbJMY9nu1ekRB51bpSMm3DgOsIf3TrqFrW4YflLnLzWIGQTT/K7p+Fjw+uf3GMNojrH8sTT1TyBustt08fobm0iRQq1FhT0xRzz+NsAqGC+DdbbNyUYoKvKGJ3sFvX0XJc65eFu5Zafaf9XidubG9l44INEkNGmpzwY5zy+M=" 16 | on: 17 | tags: true 18 | repo: agco/harvesterjs 19 | branch: master 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.13.0 2 | 3 | ENV HOME=/home/app/ 4 | 5 | COPY package.json $HOME/harvesterjs/ 6 | 7 | WORKDIR $HOME/harvesterjs/ 8 | 9 | RUN npm install --progress=false 10 | 11 | COPY . $HOME/harvesterjs/ 12 | 13 | CMD ["npm", "test"] 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Dali Zheng `` 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harvester.js 2 | 3 | Easily create [JSONAPI](http://jsonapi.org/)-compliant APIs on top of node.js and mongoDB. 4 | 5 | Pluggable with the [agco JSON-API extension](https://github.com/agco/agco-json-api-profiles) profile implementation : [Elastic Harvester](https://github.com/agco/elastic-harvesterjs), which offers additional features such as [linked resource filtering and aggregation](https://github.com/agco/agco-json-api-profiles/blob/master/public/search-profile.md). 6 | 7 | ##### Documentation 8 | 9 | Harvesterjs is currently under heavy development, we are reworking the DSL and adding a bunch of big new features. 10 | 11 | Documentation is a pretty sparse right now, however when the dust settles a bit we will get this into proper shape with reference docs, tutorials and screencasts. 12 | 13 | 14 | ### JSON-API Features 15 | 16 | - [Resource Relationships](http://jsonapi.org/format/#document-structure-resource-relationships) 17 | - [URL Templates](http://jsonapi.org/format/#document-structure-url-templates) 18 | - [Filtering](http://jsonapi.org/format/#fetching-filtering) 19 | - [Inclusion of Linked Resources](http://jsonapi.org/format/#fetching-includes) 20 | - [Sparse fieldsets](http://jsonapi.org/format/#fetching-sparse-fieldsets) 21 | - [Sorting](http://jsonapi.org/format/#fetching-sorting) 22 | - [CRUD](http://jsonapi.org/format/#crud) 23 | - [Errors](http://jsonapi.org/format/#errors) 24 | 25 | ### Other Features 26 | 27 | - Offset based pagination 28 | - node-swagger-express ready 29 | - Extended filter operators : lt, gt, lte, gte 30 | - Mongodb change events - oplog integration 31 | 32 | ### Roadmap 33 | 34 | * JSON API 1.0 compliance 35 | * validation with [joi](https://github.com/hapijs/joi) 36 | * brand [new DSL](https://github.com/agco/harvesterjs/issues/88) with sensible defaults 37 | * [External links](https://github.com/agco/harvesterjs/issues/69) 38 | * [Bidirectional links](https://github.com/agco/harvesterjs/issues/81) 39 | * [UUIDs](https://github.com/agco/harvesterjs/issues/24) 40 | 41 | ### References 42 | This project is a fork of [fortune.js](http://fortune.js.org). Decision to fork was driven by the a) desire to keep as JSONAPI compliant as possible and b) the simplification and power derived from focusing exclusively on mongoDB as the data back-end. 43 | 44 | [![NPM](https://nodei.co/npm/harvesterjs.png)](https://nodei.co/npm/harvesterjs/) 45 | 46 | [![Build Status](https://travis-ci.org/agco/harvesterjs.svg?branch=master)](https://travis-ci.org/agco/harvesterjs) 47 | [![Coverage Status](https://coveralls.io/repos/agco/harvesterjs/badge.svg)](https://coveralls.io/r/agco/harvesterjs) 48 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | test: 4 | build: . 5 | command: bash -c "sleep 20 && npm test" 6 | depends_on: 7 | - db 8 | environment: 9 | - DEBUG= 10 | - MONGODB_URL=mongodb://db:27017/test 11 | - OPLOG_MONGODB_URL=mongodb://db:27017/local 12 | db: 13 | image: mongo:3.4.0 14 | command: mongod --replSet rs0 --oplogSize 20 --smallfiles --nojournal 15 | initReplicaset: 16 | image: mongo:3.4.0 17 | command: bash -c "sleep 10 && mongo admin --host db:27017 --eval 'printjson(rs.initiate());'" 18 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Setup Prerequisites 4 | 5 | [Nodejs](https://github.com/joyent/node/wiki/Installation) 6 | [Docker and Compose](https://docs.docker.com/compose/install/) 7 | 8 | ## Running the examples 9 | 10 | ### Start Mongodb 11 | 12 | Issue the following commmand in the examples dir 13 | 14 | docker-compose up -d 15 | 16 | This should bring up Mongodb 17 | 18 | ### Enable the Oplog 19 | 20 | In order for the [streaming change events](https://github.com/agco/agco-json-api-profiles/blob/master/public/change-events-profile.md#stream-changes) 21 | feature to work you will need to enable the oplog 22 | 23 | As a one-off execute the replSet.sh file, this will initiate a replication set and hence wake up the oplog feature. 24 | 25 | ./replSet.sh 26 | 27 | 28 | ### Run an example 29 | 30 | #### Artists insert and stream 31 | 32 | node artists_insertAndStream.js 33 | 34 | This will start up an change events consumer and insert a new entry after a couple of seconds, you should see the output of both actions in the console 35 | 36 | The resource can be accessed at the following url 37 | 38 | http://localhost:1337/artists 39 | 40 | -------------------------------------------------------------------------------- /examples/artists_insertAndStream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // eslint-disable-next-line 3 | let harvester = require('../lib/harvester'); 4 | 5 | let dockerHostURL = process.env.DOCKER_HOST; 6 | let mongodbHostname; 7 | // if Mongodb is being run from Docker the DOCKER_HOST env variable should be set 8 | // use this value to derive the hostname for the Mongodb connection params 9 | if (dockerHostURL) { 10 | mongodbHostname = require('url').parse(dockerHostURL).hostname; 11 | } else { 12 | // fallback if Mongodb is being run from the host machine 13 | mongodbHostname = '127.0.0.1'; 14 | } 15 | 16 | let apiPort = process.argv[2] || 1337; 17 | let apiHost = 'http://localhost:' + apiPort; 18 | 19 | // boot up harvester 20 | harvester({ 21 | adapter: 'mongodb', 22 | connectionString: 'mongodb://' + mongodbHostname + ':27017/test', 23 | oplogConnectionString: 'mongodb://' + mongodbHostname + ':27017/local', 24 | }) 25 | .resource('artists', { 26 | name: String, 27 | }) 28 | .listen(apiPort); 29 | 30 | // subscribe to the artists change events stream (SSE) 31 | let ess = require('agco-event-source-stream'); 32 | 33 | ess(apiHost + '/artists/changes/stream').on('data', function(data) { 34 | console.log('recevied artist change event', data); 35 | }); 36 | 37 | // add some data 38 | let $http = require('http-as-promised'); 39 | 40 | let sepultura = { 41 | artists: [ 42 | { 43 | name: 'Sepultura', 44 | }, 45 | ], 46 | }; 47 | 48 | // wait a bit for the event stream to open before posting the artist 49 | setTimeout(function() { 50 | $http 51 | .post(apiHost + '/artists', { json: sepultura }) 52 | .spread(function(response, body) { 53 | console.log(body); 54 | }) 55 | .catch(function(error) { 56 | console.error(error); 57 | }); 58 | }, 2000); 59 | -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | db: 2 | image: mongo:2.6 3 | command: mongod --replSet "test" --smallfiles 4 | expose: 5 | - "27017" 6 | ports: 7 | - "27017:27017" 8 | 9 | es: 10 | image: library/elasticsearch 11 | expose: 12 | - "9200" 13 | - "9300" 14 | ports: 15 | - "9200:9200" 16 | - "9300:9300" 17 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "harvesterjs-examples", 3 | "dependencies": { 4 | "agco-event-source-stream": "^1.1.1", 5 | "http-as-promised": "^1.0.0" 6 | }, 7 | "version": "1.0.0", 8 | "author": "", 9 | "license": "ISC", 10 | "engines": { 11 | "node": ">=4.2.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/replSet.sh: -------------------------------------------------------------------------------- 1 | docker run -it --link examples_db_1:db --rm mongo:2.6 mongo db/admin --eval "rs.initiate({_id: 'test', members: [{_id: 0, host: '127.0.0.1:27017'}]})" -------------------------------------------------------------------------------- /lib/adapter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Promise = require('bluebird'); 3 | 4 | // Pre-defined adapters 5 | let adapters = { 6 | mongodb: './adapters/mongodb', 7 | mysql: 'fortune-relational', 8 | psql: 'fortune-relational', 9 | postgres: 'fortune-relational', 10 | sqlite: 'fortune-relational', 11 | }; 12 | 13 | /* ! 14 | * Adapter for persistence layers. Adapters must implement a 15 | * private `_init` method, as well as a few methods that are 16 | * accessed publicly: 17 | * 18 | * ``` 19 | * schema, model, create, update, delete, find, findMany 20 | * ``` 21 | * 22 | * @param {Object} options 23 | * @constructor 24 | */ 25 | function Adapter(options) { 26 | let key; 27 | let methods = {}; 28 | 29 | if (options.adapter) { 30 | if (typeof options.adapter === 'string' && !!adapters[options.adapter]) { 31 | // methods = require(adapters[options.adapter]); 32 | methods = new require('./adapters/mongodb')(); 33 | } else if (typeof options.adapter === 'object') { 34 | methods = options.adapter; 35 | } 36 | for (key in methods) { 37 | this[key] = methods[key]; 38 | } 39 | } else { 40 | throw new Error('Missing or invalid database adapter.'); 41 | } 42 | 43 | this._init(options); 44 | } 45 | 46 | /** 47 | * Constructor method. 48 | * 49 | * @api private 50 | */ 51 | Adapter.prototype._init = function() {}; 52 | 53 | /** 54 | * Transform harvester schema into a schema or model of the underlying adapter. Although this method is actually called from another module, it should not be called manually, so it is marked as private. 55 | * 56 | * @api private 57 | * @param {String} name the name of the resource 58 | * @param {Object} schema an object in the Harvester schema format 59 | * @param {Object} [options] optional schema options to pass to the adapter 60 | * @return {Object} 61 | */ 62 | Adapter.prototype.schema = function() {}; 63 | 64 | /** 65 | * Set up the underlying model. If no schema is passed, it returns an existing model. Although this method is actually called from another module, it should not be called manually, so it is marked as private. 66 | * 67 | * @api private 68 | * @param {String} name the name of the resource 69 | * @param {Object} [schema] if no schema is passed, this returns a model with the corresponding name 70 | * @return {Object} 71 | */ 72 | Adapter.prototype.model = function() {}; 73 | 74 | /** 75 | * Create a resource, with an optional ID. 76 | * 77 | * @param {String|Object} model either a string or the underlying model 78 | * @param {String} [id] the resource ID 79 | * @param {Object} resource a single resource in JSON API format 80 | * @return {Promise} 81 | */ 82 | Adapter.prototype.create = function() { 83 | return stubPromise(); 84 | }; 85 | 86 | /** 87 | * Update a resource by ID. 88 | * 89 | * @param {String|Object} model either a string or the underlying model 90 | * @param {String} id the resource ID 91 | * @param {Object} update a partial resource in JSON API format 92 | * @return {Promise} 93 | */ 94 | Adapter.prototype.update = function() { 95 | return stubPromise(); 96 | }; 97 | 98 | /** 99 | * Delete a resource by ID. 100 | * 101 | * @param {String|Object} model either a string or the underlying model 102 | * @param {String} id the resource ID 103 | * @return {Promise} 104 | */ 105 | Adapter.prototype.delete = function() { 106 | return stubPromise(); 107 | }; 108 | 109 | /** 110 | * Find a single resource by ID or arbitrary query. 111 | * 112 | * @param {String|Object} model if the model is a string, it looks up the model based it's name 113 | * @param {String|Object} query if the query is a string, then it is assumed that it's the ID 114 | * @return {Promise} 115 | */ 116 | Adapter.prototype.find = function() { 117 | return stubPromise(); 118 | }; 119 | 120 | /** 121 | * Find multiple resources by IDs or an arbitrary query. 122 | * 123 | * @param {String|Object} model either a string or the underlying model 124 | * @param {Array|Object} [query] either an array of IDs, or a query object 125 | * @param {Number} [limit] limit the number of resources to send back. Default: 1,000 126 | * @param {Number} [offset] skip a number of resources to send back. Default: 0 127 | * @param {Object} [sort] sort the resources to send back. Default: {"_id":-1} 128 | 129 | * @return {Promise} 130 | */ 131 | Adapter.prototype.findMany = function() { 132 | return stubPromise(); 133 | }; 134 | 135 | /** 136 | * Sometimes we need to wait for the database connection first. 137 | * This is a stub method that should return a promise, and it should 138 | * only be implemented if the need arises. 139 | * 140 | * @return {Promise} 141 | */ 142 | Adapter.prototype.awaitConnection = function() { 143 | return stubPromise(true); 144 | }; 145 | 146 | /** 147 | * Stub promise returner. 148 | * 149 | * @api private 150 | * @param {Boolean} silent 151 | * @return {Promise} 152 | */ 153 | function stubPromise(silent) { 154 | if (!silent) { 155 | console.warn('Warning: method not implemented.'); 156 | } 157 | return Promise.resolve(); 158 | } 159 | 160 | module.exports = module.exports = Adapter; 161 | module.exports.adapters = adapters; 162 | -------------------------------------------------------------------------------- /lib/adapters/mongodb.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let mongoose = require('mongoose'); 3 | let Promise = require('bluebird').Promise; 4 | let _ = require('lodash'); 5 | let uuid = require('node-uuid'); 6 | 7 | let uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; 8 | 9 | /* ! 10 | * ReplSet connection string check. 11 | */ 12 | let rgxReplSet = /^.+,.+$/; 13 | 14 | function Adapter() { 15 | var adapter = {}; 16 | adapter._init = function(options) { 17 | let connectionString = options.connectionString || ''; 18 | let oplogConnectionString = options.oplogConnectionString || ''; 19 | 20 | if (!connectionString.length) { 21 | connectionString = 22 | 'mongodb://' + 23 | (options.username 24 | ? options.username + ':' + options.password + '@' 25 | : '') + 26 | options.host + 27 | (options.port ? ':' + options.port : '') + 28 | '/' + 29 | options.db; 30 | } 31 | 32 | // always include keepAlive in connection options 33 | // see: http://mongoosejs.com/docs/connections.html 34 | let keepAlive = { keepAlive: 1 }; 35 | let gooseOpt = options.flags || {}; 36 | 37 | // Set bluebird as default promise handler 38 | mongoose.Promise = require('bluebird'); 39 | gooseOpt.promiseLibrary = require('bluebird'); 40 | 41 | gooseOpt.server = gooseOpt.server || {}; 42 | gooseOpt.server.socketOptions = gooseOpt.server.socketOptions || {}; 43 | _.assign(gooseOpt.server.socketOptions, keepAlive); 44 | if (rgxReplSet.test(connectionString)) { 45 | gooseOpt.replset = gooseOpt.replset || {}; 46 | gooseOpt.replset.socketOptions = gooseOpt.replset.socketOptions || {}; 47 | _.assign(gooseOpt.replset.socketOptions, keepAlive); 48 | } 49 | 50 | function handleDbError(err) { 51 | console.error(err); 52 | throw err; 53 | } 54 | 55 | this.db = mongoose.createConnection( 56 | connectionString, 57 | _.cloneDeep(gooseOpt) 58 | ); 59 | this.db.on('error', handleDbError); 60 | 61 | this.oplogDB = mongoose.createConnection( 62 | oplogConnectionString, 63 | _.cloneDeep(gooseOpt) 64 | ); 65 | this.oplogDB.on('error', handleDbError); 66 | }; 67 | 68 | /** 69 | * Store models in an object here. 70 | * 71 | * @api private 72 | */ 73 | adapter._models = {}; 74 | 75 | adapter.schema = function(name, schema, options) { 76 | var Mixed = mongoose.Schema.Types.Mixed; 77 | 78 | schema._id = { 79 | type: String, 80 | default: function() { 81 | return uuid.v4(); 82 | }, 83 | }; 84 | 85 | _.each(schema, function(val, key) { 86 | var obj = {}; 87 | var isArray = _.isArray(val); 88 | var value = isArray ? val[0] : val; 89 | var isObject = _.isPlainObject(value); 90 | var ref = isObject ? value.ref : value; 91 | 92 | // Convert strings to associations 93 | if (typeof ref === 'string') { 94 | obj.ref = ref; 95 | obj.type = String; 96 | schema[key] = isArray ? [obj] : obj; 97 | } 98 | 99 | // Convert native object to schema type Mixed 100 | if (typeof value === 'function' && typeCheck(value) == 'object') { 101 | if (isObject) { 102 | schema[key].type = Mixed; 103 | } else { 104 | schema[key] = Mixed; 105 | } 106 | } 107 | }); 108 | 109 | return mongoose.Schema(schema, options); 110 | 111 | function typeCheck(fn) { 112 | return Object.prototype.toString 113 | .call(new fn('')) 114 | .slice(1, -1) 115 | .split(' ')[1] 116 | .toLowerCase(); 117 | } 118 | }; 119 | 120 | adapter.model = function(name, schema) { 121 | if (schema) { 122 | var model = this.db.model.apply(this.db, arguments); 123 | this._models[name] = model; 124 | return model; 125 | } 126 | return this._models[name]; 127 | }; 128 | 129 | adapter.create = function(model, id, resource) { 130 | var _this = this; 131 | if (!resource) { 132 | resource = id; 133 | } else { 134 | resource.id = id; 135 | } 136 | model = typeof model === 'string' ? this.model(model) : model; 137 | resource = this._serialize(model, resource); 138 | return new Promise(function(resolve, reject) { 139 | model.create(resource, function(error, resource) { 140 | _this._handleWrite(model, resource, error, resolve, reject); 141 | }); 142 | }); 143 | }; 144 | 145 | adapter.update = function(model, id, update, options) { 146 | var _this = this; 147 | model = typeof model === 'string' ? this.model(model) : model; 148 | update = this._serialize(model, update); 149 | 150 | return new Promise(function(resolve, reject) { 151 | var cb = function(error, resource) { 152 | if (error) { 153 | return reject(error); 154 | } 155 | _this._handleWrite(model, resource, error, resolve, reject); 156 | }; 157 | 158 | if (options) { 159 | options.new = true; 160 | model.findByIdAndUpdate(id, update, options, cb); 161 | } else { 162 | model.findByIdAndUpdate(id, update, { new: true }, cb); 163 | } 164 | }); 165 | }; 166 | 167 | // nb: query cannot be a string in this case. 168 | adapter.upsert = function(model, query, update) { 169 | var _this = this; 170 | model = typeof model === 'string' ? this.model(model) : model; 171 | update = this._serialize(model, update); 172 | 173 | return new Promise(function(resolve, reject) { 174 | var cb = function(error, resource) { 175 | if (error) { 176 | return reject(error); 177 | } 178 | _this._handleWrite(model, resource, error, resolve, reject); 179 | }; 180 | 181 | model.findOneAndUpdate(query, update, { update: true, new: true }, cb); 182 | }); 183 | }; 184 | 185 | adapter.delete = function(model, id) { 186 | var _this = this; 187 | model = typeof model === 'string' ? this.model(model) : model; 188 | return new Promise(function(resolve, reject) { 189 | model.findByIdAndRemove(id, function(error, resource) { 190 | _this._handleWrite(model, resource, error, resolve, reject); 191 | }); 192 | }); 193 | }; 194 | 195 | adapter.find = function(model, query) { 196 | var _this = this; 197 | var method = typeof query !== 'object' ? 'findById' : 'findOne'; 198 | 199 | model = typeof model === 'string' ? this._models[model] : model; 200 | return new Promise(function(resolve, reject) { 201 | model[method](query, function(error, resource) { 202 | if (error) { 203 | return reject(error); 204 | } 205 | resolve(_this._deserialize(model, resource)); 206 | }); 207 | }); 208 | }; 209 | 210 | adapter.findMany = function(model, query, limit, skip, sort, fields) { 211 | var _this = this; 212 | 213 | if (_.isObject(query)) { 214 | query.id && (query._id = query.id) && delete query.id; 215 | } 216 | query && 217 | query._id && 218 | _.isArray(query._id) && 219 | (query._id = { $in: query._id }); 220 | 221 | if (_.isArray(query)) { 222 | query = query.length ? { _id: { $in: query } } : {}; 223 | } else if (!query) { 224 | query = {}; 225 | } else if (typeof query === 'number') { 226 | limit = query; 227 | } 228 | 229 | model = typeof model === 'string' ? this._models[model] : model; 230 | limit = limit || 1000; 231 | skip = skip ? skip : 0; 232 | sort = sort || { _id: -1 }; 233 | var arr = fields ? fields.split(' ') : []; 234 | _.each(arr, function(field, index) { 235 | arr[index] = field.replace('links.', ''); 236 | }); 237 | fields && (fields = arr.join(' ')); 238 | return new Promise(function(resolve, reject) { 239 | model 240 | .find(query) 241 | .skip(skip) 242 | .sort(sort) 243 | .limit(limit) 244 | .select(fields) 245 | .exec(function(error, resources) { 246 | if (error) { 247 | return reject(error); 248 | } 249 | resources = resources.map(function(resource) { 250 | return _this._deserialize(model, resource); 251 | }); 252 | resolve(resources); 253 | }); 254 | }); 255 | }; 256 | 257 | adapter.awaitConnection = function() { 258 | var _this = this; 259 | return new Promise(function(resolve, reject) { 260 | // check whether db isn't already in connected state 261 | // if so it wil not emit the connected event and therefore can keep the promise dangling 262 | if (_this.db._readyState == 1) { 263 | return resolve(); 264 | } 265 | _this.db.once('connected', function() { 266 | resolve(); 267 | }); 268 | _this.db.once('error', function(error) { 269 | reject(error); 270 | }); 271 | }); 272 | }; 273 | 274 | /** 275 | * Parse incoming resource. 276 | * 277 | * @api private 278 | * @param {Object} model 279 | * @param {Object} resource 280 | * @return {Object} 281 | */ 282 | adapter._serialize = function(model, resource) { 283 | if (resource.id) { 284 | resource._id = resource.id; 285 | delete resource.id; 286 | } 287 | 288 | if ( 289 | resource.hasOwnProperty('links') && 290 | typeof resource.links === 'object' 291 | ) { 292 | _.each(resource.links, function(value, key) { 293 | resource[key] = value; 294 | }); 295 | delete resource.links; 296 | } 297 | return resource; 298 | }; 299 | 300 | /** 301 | * Return a resource ready to be sent back to client. 302 | * 303 | * @api private 304 | * @param {Object} model 305 | * @param {Object} resource mongoose document 306 | * @return {Object} 307 | */ 308 | adapter._deserialize = function(model, resource) { 309 | var json = {}; 310 | if (!resource) { 311 | return undefined; 312 | } 313 | if (resource.toObject) { 314 | resource = resource.toObject(); 315 | } 316 | 317 | json.id = resource._id; 318 | 319 | var relations = []; 320 | model.schema.eachPath(function(path, type) { 321 | if (path == '_id' || path == '__v') { 322 | return; 323 | } 324 | json[path] = resource[path]; 325 | // Distinguish between refs with UUID values and properties with UUID values 326 | var hasManyRef = 327 | type.options.type && type.options.type[0] && type.options.type[0].ref; 328 | var isRef = !!(type.options.ref || hasManyRef); 329 | var instance = 330 | type.instance || (type.caster ? type.caster.instance : undefined); 331 | if ( 332 | path != '_id' && 333 | instance == 'String' && 334 | uuidRegexp.test(resource[path]) && 335 | isRef 336 | ) { 337 | return relations.push(path); 338 | } 339 | 340 | if (resource[path] && resource[path].forEach) { 341 | var isLink = _.every(resource[path], function(item) { 342 | return uuidRegexp.test(item); 343 | }); 344 | 345 | if (isLink) { 346 | relations.push(path); 347 | } 348 | } 349 | }); 350 | 351 | if (relations.length) { 352 | var links = {}; 353 | _.each(relations, function(relation) { 354 | if ( 355 | _.isArray(json[relation]) ? json[relation].length : json[relation] 356 | ) { 357 | links[relation] = json[relation]; 358 | } 359 | delete json[relation]; 360 | }); 361 | if (_.keys(links).length) { 362 | json.links = links; 363 | } 364 | } 365 | return json; 366 | }; 367 | 368 | /** 369 | * What happens after the DB has been written to, successful or not. 370 | * 371 | * @api private 372 | * @param {Object} model 373 | * @param {Object} resource 374 | * @param {Object} error 375 | * @param {Function} resolve 376 | * @param {Function} reject 377 | */ 378 | adapter._handleWrite = function(model, resource, error, resolve, reject) { 379 | var _this = this; 380 | if (error) { 381 | return reject(error); 382 | } 383 | resolve(_this._deserialize(model, resource)); 384 | }; 385 | 386 | // expose mongoose 387 | adapter.mongoose = mongoose; 388 | return adapter; 389 | } 390 | module.exports = Adapter; 391 | -------------------------------------------------------------------------------- /lib/events-reader-checkpoint-writer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const EventEmitter = require('events'); 3 | const debug = require('debug')('events-reader'); 4 | let writerLoopStopped = true; 5 | let lastDoc; 6 | let lastCheckpointId; 7 | let harvesterApp; 8 | 9 | const checkpointEventEmitter = new EventEmitter(); 10 | 11 | checkpointEventEmitter.on('newCheckpoint', (checkpointId, doc) => { 12 | lastCheckpointId = checkpointId; 13 | lastDoc = doc; 14 | }); 15 | 16 | const persistLastCheckpoint = () => { 17 | if (lastDoc && lastCheckpointId) { 18 | harvesterApp.adapter 19 | .update('checkpoint', lastCheckpointId, { ts: lastDoc.ts }) 20 | .then(checkpoint => { 21 | debug('last written checking point ' + JSON.stringify(checkpoint)); 22 | }) 23 | .catch(error => { 24 | console.log(error); 25 | // eslint-disable-next-line 26 | process.exit(1); 27 | }); 28 | } 29 | lastCheckpointId = undefined; 30 | lastDoc = undefined; 31 | }; 32 | 33 | const persistInInterval = ms => { 34 | setInterval(() => { 35 | persistLastCheckpoint(); 36 | }, ms); 37 | }; 38 | 39 | const startWriterLoop = app => { 40 | harvesterApp = app; 41 | const defaultWriteInterval = 1; 42 | const writeInterval = parseInt( 43 | (harvesterApp.options && harvesterApp.options.eventsReaderDebounceWait) || 44 | defaultWriteInterval 45 | ); 46 | if (writerLoopStopped) { 47 | persistInInterval(writeInterval); 48 | writerLoopStopped = false; 49 | } 50 | }; 51 | 52 | const getLastCheckpointId = () => lastCheckpointId; 53 | 54 | const getLastDoc = () => lastDoc; 55 | 56 | const setWriterLoopStopped = shouldStop => (writerLoopStopped = shouldStop); 57 | 58 | module.exports = { 59 | startWriterLoop: startWriterLoop, 60 | checkpointEventEmitter: checkpointEventEmitter, 61 | getLastCheckpointId: getLastCheckpointId, 62 | getLastDoc: getLastDoc, 63 | setWriterLoopStopped: setWriterLoopStopped, 64 | }; 65 | -------------------------------------------------------------------------------- /lib/events-reader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'), 3 | inflect = require('i')(), 4 | Promise = require('bluebird'), 5 | debug = require('debug')('events-reader'), 6 | hl = require('highland'), 7 | checkpointWriter = require('./events-reader-checkpoint-writer'), 8 | Joi = require('joi'); 9 | 10 | const mongoose = require('mongoose'); 11 | const Timestamp = mongoose.mongo.Timestamp; 12 | 13 | module.exports = function(harvesterApp) { 14 | return function(oplogMongodbUri) { 15 | checkpointWriter.startWriterLoop(harvesterApp); 16 | 17 | var opMap = { 18 | i: 'insert', 19 | u: 'update', 20 | d: 'delete', 21 | }; 22 | 23 | 24 | var docStream = hl(); 25 | 26 | function EventsReader() { 27 | this.stopped; 28 | } 29 | 30 | EventsReader.prototype.stop = function() { 31 | this.stopRequested = true; 32 | return this.stopped.promise; 33 | }; 34 | 35 | EventsReader.prototype.tail = function() { 36 | this.stopRequested = false; 37 | this.stopped = Promise.defer(); 38 | 39 | var that = this; 40 | 41 | return harvesterApp.adapter 42 | .find('checkpoint', {}) 43 | .then(function(checkpoint) { 44 | if (!checkpoint) { 45 | debug('checkpoint missing, creating... '); 46 | harvesterApp.adapter 47 | .create('checkpoint', { 48 | ts: Timestamp(0, Date.now() / 1000), 49 | }) 50 | .then(function() { 51 | // If a stop was requested just before here, then tailing would no longer be correct! 52 | if (!that.stopRequested) { 53 | that.streamDestroyed = false; 54 | that.tail(); 55 | } 56 | }); 57 | } else { 58 | debug('reading with checkpoint ' + logTs(checkpoint.ts)); 59 | 60 | that.checkpoint = checkpoint; 61 | that.stream = that.oplogStream(oplogMongodbUri, checkpoint.ts); 62 | that.streamDestroyed = false; 63 | setTimeout(that.read.bind(that), 0); 64 | } 65 | }) 66 | .catch(function(err) { 67 | that.exit(err); 68 | }); 69 | }; 70 | 71 | EventsReader.prototype.read = function() { 72 | var that = this; 73 | 74 | var doc; 75 | doc = that.stream.read(); 76 | 77 | var promises = that.processDocHandlers(doc); 78 | 79 | Promise.all(promises) 80 | .then(function() { 81 | return that.updateCheckpointAndReschedule(doc); 82 | }) 83 | .catch(function(err) { 84 | that.exit(err); 85 | }); 86 | }; 87 | 88 | EventsReader.prototype.oplogStream = function(oplogMongodbUri, since) { 89 | var time, 90 | query = {}, 91 | options = { 92 | tailable: true, 93 | awaitData: true, 94 | timeout: false, 95 | }; 96 | 97 | time = { $gt: since }; 98 | query.ts = time; 99 | return mongoose.connection.db.collection('oplog.rs').find(query, options); 100 | }; 101 | 102 | EventsReader.prototype.processDocHandlers = function(doc) { 103 | var that = this; 104 | var promises = []; 105 | 106 | if (doc != null) { 107 | debug( 108 | 'got data from oplog ' + JSON.stringify(doc) + ' ts: ' + logTs(doc.ts) 109 | ); 110 | 111 | var matchedChangeHandlers = matchChangeChandlers( 112 | harvesterApp.changeHandlers, 113 | doc.ns 114 | ); 115 | _.forEach(matchedChangeHandlers, function(changeHandler) { 116 | var asyncInMemory = changeHandler.asyncInMemory; 117 | 118 | if (!asyncInMemory) { 119 | var dfd = Promise.defer(); 120 | promises.push(dfd.promise); 121 | processWithHandlerT(that, changeHandler, doc, dfd); 122 | } else { 123 | _.delay(processWithHandlerT, 0, that, changeHandler, doc, null); 124 | } 125 | }); 126 | } 127 | return promises; 128 | }; 129 | 130 | function matchChangeChandlers(changeHandlersPerResource, ns) { 131 | return _.chain(changeHandlersPerResource) 132 | .filter(function(changeHandler, resource) { 133 | var resourcePlural = inflect.pluralize(resource); 134 | var regex = new RegExp('.*\\.' + resourcePlural + '$', 'i'); 135 | 136 | return regex.test(ns); 137 | }) 138 | .flatten() 139 | .value(); 140 | } 141 | 142 | function processWithHandler(that, changeHandler, doc, dfd) { 143 | var op = doc.op; 144 | 145 | if (op === 'i' || op === 'u' || op === 'd') { 146 | var id; 147 | if (op === 'u') { 148 | id = doc.o2._id; 149 | } else { 150 | id = doc.o._id; 151 | } 152 | 153 | var changeHandlerOp = opMap[op]; 154 | var opFn = changeHandler[changeHandlerOp]; 155 | 156 | if (_.isFunction(opFn)) { 157 | executeHandler( 158 | that, 159 | id, 160 | dfd, 161 | opFn, 162 | changeHandler, 163 | changeHandlerOp, 164 | doc 165 | ); 166 | } else if (opFn && typeof opFn.func === 'function') { 167 | checkFilterExecuteHandler( 168 | that, 169 | id, 170 | dfd, 171 | opFn, 172 | changeHandler, 173 | changeHandlerOp, 174 | doc 175 | ); 176 | } else if (that) { 177 | that.skip(dfd, doc); 178 | } 179 | } else if (that) { 180 | that.skip(dfd, doc); 181 | } 182 | } 183 | 184 | var throttle = require('throttle-function'); 185 | var processWithHandlerT = throttle(processWithHandler, { 186 | // call a maximum of 100 times per 1s window 187 | window: 1, 188 | limit: parseInt( 189 | _.get(harvesterApp, 'options.eventsReaderThrottleLimit'), 190 | 10 191 | ) || 100, 192 | }); 193 | 194 | function executeHandler( 195 | that, 196 | id, 197 | dfd, 198 | opFn, 199 | changeHandler, 200 | changeHandlerOp, 201 | doc 202 | ) { 203 | debug('processing resource op ' + changeHandlerOp); 204 | 205 | new Promise(function(resolve) { 206 | resolve(opFn(id)); 207 | }) 208 | .then(function() { 209 | if (dfd) { 210 | dfd.resolve(doc); 211 | } 212 | }) 213 | .catch(function(err) { 214 | console.trace(err); 215 | debug('onChange handler raised an error, retrying in 500ms.'); 216 | _.delay(processWithHandlerT, 500, that, changeHandler, doc, dfd); 217 | }); 218 | } 219 | 220 | function checkFilterExecuteHandler( 221 | that, 222 | id, 223 | dfd, 224 | opFn, 225 | changeHandler, 226 | changeHandlerOp, 227 | doc 228 | ) { 229 | if (opFn.filter) { 230 | var filter = 'o.$set.' + opFn.filter; 231 | debug('filtering on ' + filter); 232 | var test = _.has(doc, filter, false); 233 | debug('filter exists ' + test); 234 | if (test) { 235 | return executeHandler( 236 | that, 237 | id, 238 | dfd, 239 | opFn.func, 240 | changeHandler, 241 | changeHandlerOp, 242 | doc 243 | ); 244 | } else { 245 | return that.skip(dfd, doc); 246 | } 247 | } 248 | return executeHandler( 249 | that, 250 | id, 251 | dfd, 252 | opFn.func, 253 | changeHandler, 254 | changeHandlerOp, 255 | doc 256 | ); 257 | } 258 | 259 | EventsReader.prototype.skip = function(dfd, doc) { 260 | debug('skipping doc ' + JSON.stringify(doc)); 261 | if (dfd) { 262 | dfd.resolve(true); 263 | } 264 | }; 265 | 266 | EventsReader.prototype.updateCheckpointAndReschedule = function(doc) { 267 | var that = this; 268 | if (doc != null) { 269 | var regexCheckpoint = new RegExp('.*\\.checkpoints$', 'i'); 270 | var matchCheckpoint = regexCheckpoint.test(doc.ns); 271 | 272 | if (!matchCheckpoint) { 273 | debug('updating checkpoint with ts: ' + logTs(doc.ts)); 274 | checkpointWriter.checkpointEventEmitter.emit( 275 | 'newCheckpoint', 276 | that.checkpoint.id, 277 | doc 278 | ); 279 | 280 | that.reschedule(0); 281 | } else { 282 | that.reschedule(0); 283 | } 284 | } else { 285 | that.reschedule(0); 286 | } 287 | }; 288 | 289 | EventsReader.prototype.reschedule = function(time) { 290 | if (!this.stopRequested) { 291 | setTimeout(this.read.bind(this), time); 292 | } else { 293 | try { 294 | if (!this.streamDestroyed) { 295 | this.stream.destroy(); 296 | } 297 | this.streamDestroyed = true; 298 | this.stopped.resolve(); 299 | } catch (e) { 300 | this.stopped.reject(e); 301 | } 302 | } 303 | }; 304 | 305 | EventsReader.prototype.exit = function(err) { 306 | console.error(err); 307 | debug('error occurred, force exit in order to respawn process'); 308 | // process.exit(1); 309 | }; 310 | 311 | function logTs(ts) { 312 | return ( 313 | ts.getHighBits() + 314 | ' ' + 315 | ts.getLowBits() + 316 | ' ' + 317 | new Date(ts.getHighBits() * 1000) 318 | ); 319 | } 320 | 321 | docStream 322 | .map(function(doc) { 323 | var matched = matchChangeChandlers(harvesterApp.changeHandlers, doc.ns); 324 | _.forEach(matched, function(changeHandler) { 325 | processWithHandlerT(null, changeHandler, doc, null); 326 | }); 327 | }) 328 | .parallel(100); 329 | 330 | return new Promise(function(resolve, reject) { 331 | if (!harvesterApp.adapter.model('checkpoint')) { 332 | harvesterApp.resource('checkpoint', { 333 | ts: Joi.any(), 334 | }); 335 | } 336 | mongoose 337 | .connect(oplogMongodbUri, { 338 | poolSize: 10, 339 | socketOptions: { keepAlive: 250 }, 340 | }) 341 | .then(() => { 342 | debug('EventsReader connected to oplog'); 343 | resolve(EventsReader); 344 | }) 345 | .catch(err => reject(err)); 346 | }); 347 | }; 348 | }; 349 | -------------------------------------------------------------------------------- /lib/includes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Promise = require('bluebird'), 3 | _ = require('lodash'), 4 | inflect = require('i')(), 5 | $http = require('http-as-promised'); 6 | 7 | module.exports = function(adapter, schemas) { 8 | function linked(body, inclusions) { 9 | var primaryResourceName = extractPrimaryResource(body); 10 | // a bit dirty, but necessary to avoid total refactor of sendResponse 11 | function extractPrimaryResource(body) { 12 | return _.first( 13 | _.filter(_.keys(body), function(key) { 14 | return !(key === 'meta' || key === 'links'); 15 | }) 16 | ); 17 | } 18 | 19 | var modelName = inflect.singularize(primaryResourceName); 20 | var primarySchema = schemas[modelName]; 21 | 22 | function isRemote(branch, key) { 23 | return ( 24 | branch != null && 25 | branch.refs != null && 26 | branch.refs[key] != null && 27 | branch.refs[key].def != null && 28 | branch.refs[key].def.baseUri != null 29 | ); 30 | } 31 | 32 | function toIncludes(map) { 33 | var includes = []; 34 | 35 | function buildRecursively(map, prefix) { 36 | prefix = prefix || ''; 37 | return _.forEach(map, function(value, key) { 38 | if (_.isEmpty(value)) { 39 | includes.push(prefix + key); 40 | } else { 41 | var newPrefix = prefix + key + '.'; 42 | buildRecursively(value, newPrefix); 43 | } 44 | }); 45 | } 46 | 47 | buildRecursively(map); 48 | return includes.join(','); 49 | } 50 | 51 | function mapToInclusionTree(typeMap, schema, path) { 52 | path = path || []; 53 | var branch = _.map(typeMap, function(value, key) { 54 | path.push(key); 55 | var subBranch = buildInclusionBranch([key], schema); 56 | if (!_.isEmpty(value)) { 57 | if (isRemote(subBranch, key)) { 58 | subBranch.refs[key].def.remoteIncludes = toIncludes(value); 59 | } else { 60 | var schemaName = schema[key]; 61 | var subMap = mapToInclusionTree(value, schemas[schemaName], path); 62 | _.merge(subBranch.refs[key], subMap); 63 | } 64 | } 65 | path.pop(); 66 | return subBranch; 67 | }); 68 | return _.reduce( 69 | branch, 70 | function(acc, item) { 71 | return _.merge(acc, item); 72 | }, 73 | {} 74 | ); 75 | } 76 | 77 | var inclusionMap = _.map(inclusions, function(inclusionString) { 78 | var result = {}; 79 | var node = result; 80 | inclusionString.split('.').forEach(function(token) { 81 | node = node[token] = {}; 82 | }); 83 | return result; 84 | }).reduce(function(acc, node) { 85 | return _.merge(acc, node); 86 | }, {}); 87 | 88 | var inclusionTree = mapToInclusionTree(inclusionMap, primarySchema); 89 | 90 | // builds a tree representation out of a series of inclusion tokens 91 | function buildInclusionBranch(inclusionTokens, schema) { 92 | var inclusionToken = _.first(inclusionTokens); 93 | var type = _.isArray(schema[inclusionToken]) 94 | ? schema[inclusionToken][0] 95 | : schema[inclusionToken]; 96 | var normalisedType = _.isPlainObject(type) ? type.ref : type; 97 | 98 | var linkDescriptor = { 99 | def: { type: normalisedType }, 100 | }; 101 | 102 | var baseUri = _.isPlainObject(type) ? type.baseUri : null; 103 | if (baseUri) { 104 | var remoteIncludes = _.drop( 105 | inclusionTokens, 106 | _.indexOf(inclusionTokens, inclusionToken) + 1 107 | ).join('.'); 108 | var remoteDescriptor = _.merge(linkDescriptor, { 109 | def: { baseUri: baseUri, remoteIncludes: remoteIncludes }, 110 | }); 111 | return setRefs(remoteDescriptor); 112 | } else { 113 | var tokensRemaining = _.drop(inclusionTokens); 114 | if (tokensRemaining.length == 0) { 115 | return setRefs(linkDescriptor); 116 | } else { 117 | return _.merge( 118 | linkDescriptor, 119 | buildInclusionBranch(tokensRemaining, schemas[normalisedType]) 120 | ); 121 | } 122 | } 123 | 124 | function setRefs(descriptor) { 125 | return _.set({}, 'refs.' + inclusionToken, descriptor); 126 | } 127 | } 128 | 129 | var resources = body[primaryResourceName]; 130 | return fetchLinked({}, resources, inclusionTree).then(function(linked) { 131 | return _.merge(body, linked); 132 | }); 133 | } 134 | 135 | function fetchLinked(fetchedIds, resources, inclusionBranch) { 136 | return Promise.all( 137 | _.map(_.keys(inclusionBranch ? inclusionBranch.refs : []), function( 138 | inclusionRefKey 139 | ) { 140 | return fetchResources( 141 | fetchedIds, 142 | resources, 143 | inclusionBranch, 144 | inclusionRefKey 145 | ).then(function(result) { 146 | if (result) { 147 | // process all entries as one of the inclusionBranch descriptor might have had a remoteInclude property set 148 | // which will yield more reslts than 'inclusionRefKey' only 149 | var key; 150 | var mergedLinkedResources = _.reduce( 151 | result, 152 | function(acc, linkedResources, linkedResName) { 153 | key = linkedResName; 154 | var concatExisting = _.merge( 155 | linkedResources, 156 | acc.linked[linkedResName] 157 | ); 158 | return _.set(acc, 'linked.' + [linkedResName], concatExisting); 159 | }, 160 | { linked: {} } 161 | ); 162 | // recur and fetch the linked resources for the next inclusionBranch 163 | return fetchLinked( 164 | fetchedIds, 165 | mergedLinkedResources.linked[key], 166 | inclusionBranch.refs[inclusionRefKey] 167 | ).then(function(result) { 168 | return _.merge(mergedLinkedResources, result, function(a, b) { 169 | return _.isArray(a) ? a.concat(b) : undefined; 170 | }); 171 | }); 172 | } 173 | return {}; 174 | }); 175 | }) 176 | ).then(function(linkedResources) { 177 | return _.reduce( 178 | linkedResources, 179 | function(acc, linkedResource) { 180 | return _.merge(acc, linkedResource); 181 | }, 182 | {} 183 | ); 184 | }); 185 | } 186 | 187 | function fetchResources( 188 | fetchedIds, 189 | resources, 190 | inclusionBranch, 191 | inclusionRefKey 192 | ) { 193 | var linkedIds = getLinkedIds(resources, inclusionRefKey); 194 | if (linkedIds && linkedIds.length > 0) { 195 | var inclusionDescriptor = inclusionBranch.refs[inclusionRefKey]; 196 | var type = inclusionDescriptor.def.type; 197 | 198 | fetchedIds[type] = fetchedIds[type] || []; 199 | var remainingIds = _.without(linkedIds, fetchedIds[type]); 200 | fetchedIds[type] = fetchedIds[type].concat(remainingIds); 201 | 202 | var resourceName = inflect.pluralize(type); 203 | 204 | if (!inclusionDescriptor.def.baseUri) { 205 | return ( 206 | adapter 207 | .findMany(type, remainingIds, remainingIds.length) 208 | // todo re-add aftertransform 209 | .then(function(resources) { 210 | return _.set({}, resourceName, resources); 211 | }) 212 | ); 213 | } else { 214 | // the related resource is defined on another domain 215 | // fetch with an http call with inclusion of the deep linked resources for this resource 216 | remainingIds = _.uniq(remainingIds); 217 | return $http( 218 | inclusionDescriptor.def.baseUri + 219 | '/' + 220 | resourceName + 221 | '?id=' + 222 | remainingIds.join(',') + 223 | '&include=' + 224 | inclusionDescriptor.def.remoteIncludes, 225 | { json: true } 226 | ).spread(function(response, body) { 227 | // get results for the primary resource 228 | var primary = _.set({}, resourceName, body[resourceName]); 229 | return _.reduce( 230 | body.linked, 231 | function(accum, val, key) { 232 | // accumulate results for the linked resources 233 | return _.set(accum, key, val); 234 | }, 235 | primary 236 | ); 237 | }); 238 | } 239 | } else { 240 | return Promise.resolve(); 241 | } 242 | } 243 | 244 | function getLinkedIds(resources, path) { 245 | var ids = _.reduce( 246 | resources, 247 | function(acc, resource) { 248 | if (resource.links && resource.links[path]) { 249 | var id = resource.links[path]; 250 | if (_.isArray(id)) { 251 | acc = acc.concat(id); 252 | } else { 253 | acc.push(id); 254 | } 255 | } 256 | return acc; 257 | }, 258 | [] 259 | ); 260 | 261 | return _.uniq(ids); 262 | } 263 | 264 | return { 265 | linked: linked, 266 | }; 267 | }; 268 | -------------------------------------------------------------------------------- /lib/jsonapi-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _ = require('lodash'); 4 | 5 | // constants 6 | let MIME = { 7 | standard: ['application/vnd.api+json', 'application/json'], 8 | patch: ['application/json-patch+json'], 9 | }; 10 | 11 | let errorMessages = { 12 | 400: 'Request was malformed.', 13 | 403: 'Access forbidden.', 14 | 404: 'Resource not found.', 15 | 405: 'Method not permitted.', 16 | 412: 'Request header "Content-Type" must be one of: ' + 17 | MIME.standard.join(', '), 18 | 422: 'Unprocessable Entity.', 19 | 500: 'Oops, something went wrong.', 20 | 501: 'Feature not implemented.', 21 | }; 22 | 23 | function JSONAPI_Error(error) { 24 | this.name = 'JSONAPI_Error'; 25 | if (!error) { 26 | error = {}; 27 | } 28 | 29 | var status = error.status ? error.status : 500; 30 | 31 | var errorWithDefaults = _.merge({}, error, { 32 | status: status, 33 | href: error.href ? error.href : 'about:blank', 34 | title: error.title ? error.title : errorMessages[status], 35 | detail: error.detail ? error.detail : '', 36 | }); 37 | 38 | this.error = errorWithDefaults; 39 | } 40 | JSONAPI_Error.prototype = new Error(); 41 | JSONAPI_Error.prototype.constructor = JSONAPI_Error; 42 | 43 | module.exports = JSONAPI_Error; 44 | -------------------------------------------------------------------------------- /lib/route.method.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | 4 | let SSE = require('./sse'); 5 | let JSONAPI_Error = require('./jsonapi-error.js'); 6 | let sendError = require('./send-error'); 7 | 8 | function methodNotAllowedFunc(req, res) { 9 | sendError(req, res, new JSONAPI_Error({ status: 405 })); 10 | } 11 | 12 | /** 13 | * Create expressJS route handler. 14 | * 15 | * Depending on configuration it may leverage global authorizationStrategy 16 | * or not. 17 | * Then it delegates to apropriate handlerFunc configured during `register()`. 18 | * 19 | * @param routeMethod 20 | * @returns {Function} expressJS route handler 21 | */ 22 | function createRouteInterceptor(routeMethod) { 23 | return function routeInterceptor(request, response) { 24 | var authorizationStrategy = 25 | routeMethod.options.harvester.authorizationStrategy; 26 | var thatRouteInterceptorContext = this; 27 | // noinspection UnnecessaryLocalVariableJS 28 | var thatRouteInterceptorArguments = arguments; 29 | 30 | function callRouteHandler() { 31 | routeMethod.handlerFunc.apply( 32 | thatRouteInterceptorContext, 33 | thatRouteInterceptorArguments 34 | ); 35 | } 36 | 37 | function isAuthorizationRequired() { 38 | var methodNotAllowed = methodNotAllowedFunc === routeMethod.handlerFunc; 39 | var authorizationEnabled = routeMethod.authorizationRequired !== false; 40 | var authorizationStrategyPresent = 41 | authorizationStrategy instanceof Function; 42 | return ( 43 | authorizationEnabled && 44 | !methodNotAllowed && 45 | authorizationStrategyPresent 46 | ); 47 | } 48 | 49 | /** 50 | * This function is meant to be invoked in promise chain, which means that any exception thrown by callRouteHandler will not get out of routeInterceptor 51 | * (will be swallowed), so we need to close the connection with proper error sent to client. 52 | */ 53 | function safelyCallRouteHandler(authResult) { 54 | if (authResult instanceof Error) { 55 | return sendError(request, response, authResult); 56 | } 57 | try { 58 | callRouteHandler(); 59 | } catch (error) { 60 | console.error( 61 | 'This should never happen, but route handler for', 62 | routeMethod.options.method, 63 | routeMethod.options.route, 64 | 'threw exception!!' 65 | ); 66 | sendError(request, response, error); 67 | } 68 | } 69 | 70 | function handleAuthorizationResult(authorizationResult) { 71 | /* if the result is a promise*/ 72 | if ( 73 | authorizationResult != null && 74 | authorizationResult.then instanceof Function 75 | ) { 76 | authorizationResult 77 | .then(safelyCallRouteHandler) 78 | .catch(function(result) { 79 | var err = new JSONAPI_Error({ status: 403 }); 80 | if (result instanceof JSONAPI_Error) { 81 | err = result; 82 | } 83 | sendError(request, response, err); 84 | }); 85 | } else { 86 | /* if the result is not a promise*/ 87 | sendError(request, response, authorizationResult); 88 | } 89 | } 90 | 91 | if (isAuthorizationRequired()) { 92 | var rolesAllowed = _.isEmpty(routeMethod.roles) 93 | ? routeMethod.options.resource.roles 94 | : routeMethod.roles; 95 | var authorizationResult = authorizationStrategy( 96 | request, 97 | routeMethod.getPermissionName(), 98 | rolesAllowed || [] 99 | ); 100 | handleAuthorizationResult(authorizationResult); 101 | } else { 102 | callRouteHandler(); 103 | } 104 | }; 105 | } 106 | 107 | function RouteMethod(options) { 108 | if (options.harvester == null) { 109 | throw new Error( 110 | 'Options must include reference to harvester (property harvester)' 111 | ); 112 | } 113 | if (options.resource == null) { 114 | throw new Error('Options must include resource (property resource)'); 115 | } 116 | this.options = options; 117 | this.authorizationRequired = true; 118 | /** 119 | * The DSL defines: harvesterApp.getById(), so instead of returning this object we need to return function that, when called, will return this object. 120 | */ 121 | var that = this; 122 | return function() { 123 | return that; 124 | }; 125 | } 126 | 127 | RouteMethod.prototype.register = function() { 128 | var handlers = this.options.handlers; 129 | var route = this.options.route; 130 | var method = this.options.method; 131 | var thatRouteMethod = this; 132 | 133 | if (this.options.sse) { 134 | new SSE().init({ 135 | context: this.options.harvester, 136 | singleResourceName: this.options.resource.name, 137 | verbs: ['post', 'put', 'delete'], 138 | }); 139 | return this; 140 | } 141 | 142 | if (this.options.notAllowed) { 143 | this.handlerFunc = methodNotAllowedFunc; 144 | this.options.harvester.router[method](route, methodNotAllowedFunc); 145 | } else { 146 | this.handlerFunc = handlers[route][method]; 147 | this.options.harvester.router[method]( 148 | this.options.route, 149 | createRouteInterceptor(thatRouteMethod) 150 | ); 151 | } 152 | return this; 153 | }; 154 | 155 | RouteMethod.prototype.disableAuthorization = function() { 156 | this.authorizationRequired = false; 157 | return this; 158 | }; 159 | 160 | RouteMethod.prototype.before = function(beforeFunc) { 161 | this.beforeFunc = beforeFunc; 162 | return this; 163 | }; 164 | 165 | RouteMethod.prototype.after = function(afterFunc) { 166 | this.afterFunc = afterFunc; 167 | return this; 168 | }; 169 | 170 | RouteMethod.prototype.handler = function() { 171 | return this.handlerFunc; 172 | }; 173 | 174 | RouteMethod.prototype.getPermissionName = function() { 175 | return ( 176 | this.options.resource.name + 177 | '.' + 178 | (this.options.permissionSuffix || this.options.method) 179 | ); 180 | }; 181 | 182 | RouteMethod.prototype.validate = function(validateFunc) { 183 | this.validateFunc = validateFunc; 184 | return this; 185 | }; 186 | 187 | RouteMethod.prototype.notAllowed = function() { 188 | this.options.notAllowed = true; 189 | return this; 190 | }; 191 | 192 | RouteMethod.prototype.isAllowed = function() { 193 | return this.handlerFunc !== methodNotAllowedFunc; 194 | }; 195 | 196 | RouteMethod.prototype.roles = function() { 197 | this.roles = Array.prototype.slice.call(arguments); 198 | return this; 199 | }; 200 | 201 | module.exports = RouteMethod; 202 | -------------------------------------------------------------------------------- /lib/send-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _ = require('lodash'); 4 | 5 | let JSONAPI_Error = require('./jsonapi-error'); 6 | 7 | /** 8 | * Send a JSONAPI compatible list of errors to the client. 9 | * 10 | * @param {Object} req standard express req object 11 | * @param {Object} res standard express res object 12 | * @param {Array} error if error is not an array it's automatically promoted 13 | * to an Array. Thus this is backward compatible. 14 | * @return {undefined} no return value 15 | */ 16 | let sendError = function(req, res, error) { 17 | var errorList = []; 18 | 19 | var stringify = function(object) { 20 | return process.env.NODE_ENV === 'production' 21 | ? JSON.stringify(object, null, null) 22 | : JSON.stringify(object, null, 2) + '\n'; 23 | }; 24 | 25 | function normaliseError(error) { 26 | if (error instanceof JSONAPI_Error) { 27 | return error; 28 | } else if (error && error.status === 413) { 29 | return new JSONAPI_Error({ 30 | status: 413, 31 | detail: error && process.env.NODE_ENV !== 'production' 32 | ? error.toString() 33 | : '', 34 | }); 35 | } else { 36 | return new JSONAPI_Error({ 37 | status: 500, 38 | detail: error && process.env.NODE_ENV !== 'production' 39 | ? error.toString() 40 | : '', 41 | }); 42 | } 43 | } 44 | 45 | try { 46 | // promote error if it's not already an Array 47 | if (!(error instanceof Array)) { 48 | error = [error]; 49 | } 50 | 51 | // handle each error 52 | _.forEach(error, function A(error) { 53 | // This function is `A` as it's a "throwaway" (anonymous) function, 54 | // but anonymous functions make stack traces harder to read. There 55 | // exists a convention used by some (myself included) to name such 56 | // "throwaway" functions with a single upper case letter, to make it 57 | // clear that it's a throw away function. 58 | // see: https://docs.npmjs.com/misc/coding-style for more info. 59 | 60 | // log error if it's a 500 or strange error 61 | if ( 62 | !(error instanceof JSONAPI_Error) || 63 | (error instanceof JSONAPI_Error && error.error.status > 500) 64 | ) { 65 | console.trace((error && error.stack) || error); 66 | } 67 | 68 | // add normalised error to the list 69 | errorList.push(normaliseError(error).error); 70 | }); 71 | 72 | // send a list of errors 73 | res.set('Content-Type', 'application/vnd.api+json'); 74 | // TODO: perhaps find a better heuristic for calculating the "global" error status of this response. 75 | res.status(errorList[0].status).send(stringify({ errors: errorList })); 76 | } catch (e) { 77 | console.error('! Something broke during sendError routine !', e.stack); 78 | } 79 | }; 80 | 81 | module.exports = sendError; 82 | -------------------------------------------------------------------------------- /lib/sse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | const Timestamp = require('mongodb').Timestamp; 4 | let mongoose = require('mongoose'); 5 | let inflect = require('i')(); 6 | let tinySSE = require('tiny-sse'); 7 | let Promise = require('bluebird'); 8 | let hl = require('highland'); 9 | let JSONAPI_Error = require('./jsonapi-error'); 10 | 11 | /* 12 | Usage: 13 | ====================================== 14 | When setting up Multi SSE (ie: SSE for multiple resources), you just need to pass in Harvester context as such: 15 | 16 | this.multiSSE = new SSE(); 17 | this.multiSSE.init({ 18 | context: harvesterApp 19 | }); 20 | 21 | You can then point an EventReader to listen from "{base_url}/changes/stream?resouces=foo,bar,baz". 22 | 23 | When setting up SSE for a single route, you will need to pass the resource name: 24 | 25 | this.singleSSE = new SSE(); 26 | this.singleSSE.init({ 27 | context: harvesterApp, 28 | singleResourceName: 'foo' 29 | }); 30 | 31 | You can then point an EventReader to listen from "{base_url}/foo/changes/stream". 32 | 33 | Verbs: 34 | ====================================== 35 | You can also pass a "verbs" option to this module. If none is passed, SSE will only listen to "insert" events from uplog. 36 | Values you can pass are "post", "put" and "delete" which in turn currespond to oplog ops "i", "u" and "d". 37 | 38 | this.singleSSE = new SSE(); 39 | this.singleSSE.init({ 40 | context: harvesterApp, 41 | singleResourceName: 'foo', 42 | verbs: ['post', 'put', 'delete'] 43 | }); 44 | */ 45 | 46 | let SSE = function() {}; 47 | 48 | SSE.prototype.init = function(config) { 49 | this.config = config; 50 | this.options = config.context.options; 51 | this.harvesterApp = config.context; 52 | 53 | // only listen to post events if the verb is not specified 54 | this.verbs = config.verbs || ['post']; 55 | 56 | // wraps it up in an array of single item, so that it fits the current logic without too many conditions 57 | this.singleResourceName = config.singleResourceName && [ 58 | config.singleResourceName, 59 | ]; 60 | 61 | this.db = this.harvesterApp.adapter.oplogDB; 62 | 63 | var routePrefix = ''; 64 | 65 | if (config.singleResourceName) { 66 | var pluralName = this.options.inflect 67 | ? inflect.pluralize(config.singleResourceName) 68 | : config.singleResourceName; 69 | 70 | routePrefix = '/' + pluralName; 71 | } 72 | 73 | this.harvesterApp.router.get( 74 | this.options.baseUrl + routePrefix + '/changes/stream', 75 | this.requestValidationMiddleware.bind(this), 76 | tinySSE.head(), 77 | tinySSE.ticker({ seconds: 3 }), 78 | this.handler.bind(this) 79 | ); 80 | }; 81 | 82 | SSE.prototype.requestValidationMiddleware = function(req, res, next) { 83 | this.routeNames = req.query.resources ? req.query.resources.split(',') : []; 84 | 85 | if (this.singleResourceName) { 86 | this.routeNames = this.singleResourceName; 87 | } 88 | 89 | if (this.routeNames.length === 0) { 90 | throw new JSONAPI_Error({ 91 | status: 400, 92 | title: 'Requested changes on missing resource', 93 | detail: 'You have not specified any resources, please do so by providing "resource?foo,bar" as query', 94 | }); 95 | } 96 | 97 | if (!this.allResourcesExist(this.routeNames)) { 98 | throw new JSONAPI_Error({ 99 | status: 400, 100 | title: 'Requested changes on missing resource', 101 | detail: "The follow resources don't exist " + 102 | this.getMissingResources(this.routeNames).join(','), 103 | }); 104 | } 105 | 106 | if (req.headers['last-event-id']) { 107 | var tsSplit = _.map(req.headers['last-event-id'].split('_'), function( 108 | item 109 | ) { 110 | return parseInt(item, 10); 111 | }); 112 | 113 | var isValidTS = _.all(tsSplit, function(ts) { 114 | return !isNaN(ts); 115 | }); 116 | 117 | if (!isValidTS) { 118 | throw new JSONAPI_Error({ 119 | status: 400, 120 | title: 'Invalid Timestamp', 121 | detail: 'Could not parse the time stamp provided', 122 | }); 123 | } 124 | } 125 | 126 | next(); 127 | }; 128 | 129 | SSE.prototype.handler = function(req, res) { 130 | var oplogConnectionString = this.options.oplogConnectionString || ''; 131 | 132 | var that = this; 133 | var options = { 134 | tailable: true, 135 | awaitdata: true, 136 | oplogReplay: true, 137 | numberOfRetries: Number.MAX_VALUE, 138 | }; 139 | 140 | this.routeNames = req.query.resources ? req.query.resources.split(',') : []; 141 | 142 | if (this.singleResourceName) { 143 | this.routeNames = this.singleResourceName; 144 | } 145 | 146 | var pluralRouteNames = this.routeNames.map(function(routeName) { 147 | return inflect.pluralize(routeName); 148 | }); 149 | 150 | var regex = new RegExp('.*\\.(' + pluralRouteNames.join('|') + ')', 'i'); 151 | var docStream = hl(); 152 | 153 | // always include keepAlive in connection options 154 | // see: http://mongoosejs.com/docs/connections.html 155 | var gooseOpt = { 156 | server: { 157 | poolSize: 1, 158 | keepAlive: 1, 159 | }, 160 | }; 161 | 162 | var db = mongoose.createConnection(oplogConnectionString, gooseOpt); 163 | db.on('error', function(err) { 164 | that.handleError(err, res, docStream); 165 | }); 166 | 167 | req.on('close', function() { 168 | db.close(); 169 | }); 170 | 171 | db.once('open', function() { 172 | that 173 | .getQuery(req, regex) 174 | .then(function(query) { 175 | var collection = db.collection('oplog.rs'); 176 | var stream = collection.find(query, options).stream(); 177 | var docStream = hl(stream); 178 | 179 | docStream.resume(); 180 | 181 | var consume = hl().consume(function(err, chunk, push, next) { 182 | var routeName = ''; // routeName for the SSE, '' indicates SSE does not match a route requested. 183 | var resourceNames = _.map(that.routeNames, function(routeName) { 184 | var pluralName = that.options.inflect 185 | ? inflect.pluralize(routeName) 186 | : routeName; 187 | return new RegExp(pluralName, 'i'); 188 | }); 189 | 190 | // find routeName (if any) the event matches. 191 | _.forEach(resourceNames, function(resourceName, index) { 192 | if (resourceName.test(chunk.ns)) { 193 | routeName = that.routeNames[index]; 194 | return false; 195 | } 196 | }); 197 | 198 | if (routeName) { 199 | var id = chunk.ts.getHighBits() + '_' + chunk.ts.getLowBits(); 200 | var eventName = that.getEventName(routeName, chunk); 201 | var data = that.getData(routeName, chunk); 202 | 203 | var filters = that.getFilters(req); 204 | 205 | var passedFilter = _.reduce( 206 | filters, 207 | function(obj, filter) { 208 | return _.filter([data], _.matches(filter)); 209 | }, 210 | true 211 | ); 212 | 213 | // if we have filters, make sure they are passed 214 | if (passedFilter.length > 0 || filters.length === 0) { 215 | tinySSE.send({ id: id, event: eventName, data: data })(req, res); 216 | } 217 | 218 | next(); 219 | } 220 | }); 221 | 222 | docStream.through(consume).errors(function(err) { 223 | console.log('HARVESTER SSE ERROR>>> ' + err.stack); 224 | that.handleError(err, res, docStream); 225 | }); 226 | }) 227 | .catch(function(err) { 228 | console.log('HARVESTER SSE ERROR>>> ' + err.stack); 229 | that.handleError(err, res, docStream); 230 | }); 231 | }); 232 | }; 233 | 234 | SSE.prototype.handleError = function(err, res, docStream) { 235 | res.end(); 236 | if (docStream) { 237 | docStream.destroy(); 238 | } 239 | }; 240 | 241 | SSE.prototype.allResourcesExist = function(resourceNames) { 242 | return this.getMissingResources(resourceNames).length === 0; 243 | }; 244 | 245 | SSE.prototype.getMissingResources = function(resourceNames) { 246 | var harvesterResourceNames = 247 | this.resourceName || _.keys(this.harvesterApp.createdResources); 248 | 249 | return _.difference(resourceNames, harvesterResourceNames); 250 | }; 251 | 252 | SSE.prototype.getQuery = function(req, ns) { 253 | var lastEventId = req.headers['last-event-id']; 254 | var coll = this.db.collection('oplog.rs'); 255 | 256 | var verbs = this.verbs.map(function(verb) { 257 | return { 258 | post: 'i', 259 | put: 'u', 260 | delete: 'd', 261 | }[verb]; 262 | }); 263 | 264 | var query = { 265 | ns: ns, 266 | op: new RegExp('(' + verbs.join('|') + ')', 'i'), 267 | }; 268 | return new Promise(function(resolve, reject) { 269 | if (req.headers['last-event-id']) { 270 | var tsSplit = _.map(lastEventId.split('_'), function(item) { 271 | return parseInt(item, 10); 272 | }); 273 | 274 | query.ts = { 275 | $gt: Timestamp(tsSplit[1], tsSplit[0]), 276 | }; 277 | 278 | return resolve(query); 279 | } 280 | 281 | coll 282 | .find({ op: query.op }, { sort: { $natural: -1 }, limit: 1 }) 283 | .toArray(function(err, items) { 284 | if (err || !items) { 285 | return reject(err); 286 | } 287 | query.ts = { 288 | $gt: items[0].ts, 289 | }; 290 | 291 | return resolve(query); 292 | }); 293 | }); 294 | }; 295 | 296 | SSE.prototype.getFilters = function(req) { 297 | var filters = _.chain(req.query) 298 | .map(function(item, key) { 299 | if (!_.contains(['limit', 'sort', 'offset', 'resources'], key)) { 300 | var filter = {}; 301 | filter[key] = item; 302 | return filter; 303 | } 304 | }) 305 | .filter(function(item) { 306 | return !!item; 307 | }) 308 | // converts {'foo.bar' : 'foobar'} to {foo : { bar : 'foobar' }} 309 | .map(function(item) { 310 | var keys = _.keys(item)[0].split('.'); 311 | return _.reduce( 312 | keys, 313 | function(obj, key, index) { 314 | var value = index === keys.length - 1 || keys.length === 1 315 | ? _.values(item)[0] 316 | : {}; 317 | 318 | if (index === 0) { 319 | obj[key] = keys.length > 1 ? {} : value; 320 | } else { 321 | obj[keys[index - 1]][key] = value; 322 | } 323 | return obj; 324 | }, 325 | {} 326 | ); 327 | }) 328 | .value(); 329 | 330 | return filters; 331 | }; 332 | 333 | SSE.prototype.getData = function(routeName, chunk) { 334 | var data; 335 | var model = this.harvesterApp.adapter.model(routeName); 336 | 337 | switch(chunk.op) { 338 | case 'i': 339 | data = this.harvesterApp.adapter._deserialize(model, chunk.o); 340 | break; 341 | case 'u': 342 | data = chunk.o.$set || chunk.o; 343 | data._id = chunk.o2._id; 344 | break; 345 | case 'd': 346 | data = { _id: chunk.o._id }; 347 | break; 348 | default: 349 | data = {}; 350 | } 351 | 352 | return data; 353 | }; 354 | 355 | SSE.prototype.getEventName = function(routeName, chunk) { 356 | return inflect.pluralize(routeName) + '_' + chunk.op; 357 | }; 358 | 359 | module.exports = SSE; 360 | -------------------------------------------------------------------------------- /lib/validation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Joi = require('joi'); 3 | let _ = require('lodash'); 4 | 5 | module.exports = function(schema, options) { 6 | var abortEarly = (options && options.abortEarly) || false; 7 | var allowUnknown = (options && options.allowUnknown) || { 8 | body: false, 9 | headers: true, 10 | query: true, 11 | params: true, 12 | }; 13 | 14 | return { 15 | validate: function(request) { 16 | if (!request) { 17 | throw new Error('Please provide a request to validate'); 18 | } 19 | if (!schema) { 20 | throw new Error('Please provide a validation schema'); 21 | } 22 | 23 | var details = {}; 24 | 25 | // for each schema type (body, headers, params, headers) validate the request 26 | _.each(_.keys(schema), function(schemaType) { 27 | if (request[schemaType] && schema[schemaType]) { 28 | var validationResult = Joi.validate( 29 | request[schemaType], 30 | schema[schemaType], 31 | { allowUnknown: allowUnknown[schemaType], abortEarly: abortEarly } 32 | ); 33 | 34 | if (validationResult.error) { 35 | _.set(details, schemaType, validationResult.error.details); 36 | } 37 | } 38 | }); 39 | 40 | return details; 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "harvesterjs", 3 | "description": "a library which makes it easy to construct a JSON-API compliant REST API (http://jsonapi.org/) with Nodejs and Mongodb", 4 | "version": "3.1.2", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Kristof Sajdak", 8 | "email": "kristof.sajdak@gmail.com" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "Stephen Sebro", 13 | "email": "stephensebro@yahoo.com" 14 | } 15 | ], 16 | "homepage": "", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/agco/harvesterjs" 20 | }, 21 | "scripts": { 22 | "test": "mocha && npm run lint", 23 | "lint": "eslint .", 24 | "lint:fix": "eslint . --fix" 25 | }, 26 | "main": "./lib/harvester.js", 27 | "dependencies": { 28 | "bluebird": "^2.9.27", 29 | "body-parser": "~1.4.3", 30 | "debounce-promise": "^3.0.1", 31 | "debug": "^2.1.1", 32 | "express": "~4.6.1", 33 | "highland": "^2.5.1", 34 | "http-as-promised": "^1.1.0", 35 | "i": "~0.3.2", 36 | "joi": "^6.4.3", 37 | "lodash": "~3.7.0", 38 | "longjohn": "^0.2.4", 39 | "mkdirp": "~0.5.0", 40 | "mongodb": "^2.2.26", 41 | "mongoose": "4.10.8", 42 | "node-uuid": "^1.4.3", 43 | "throttle-function": "^0.1.0", 44 | "tiny-sse": "git+https://github.com/agco/node-tiny-sse.git", 45 | "underscore.string": "^2.4.0", 46 | "agco-logger": "git+https://github.com/agco/agco-logger.git#c14fba9712968a0509bbf2c69c33a167a6cc1b07" 47 | }, 48 | "devDependencies": { 49 | "agco-event-source-stream": "^1.1.1", 50 | "chai": "^1.10.0", 51 | "chai-http": "^1.0.0", 52 | "coveralls": "^2.11.2", 53 | "eslint": "^3.18.0", 54 | "eslint-plugin-node": "^4.2.2", 55 | "istanbul": "^0.3.7", 56 | "mocha": "3.4.2", 57 | "mocha-lcov-reporter": "0.0.2", 58 | "nock": "^0.56.0", 59 | "profanity-util": "0.0.2", 60 | "request-debug": "^0.1.1", 61 | "should": "~4.0.4", 62 | "sinon": "^1.17.6", 63 | "sleep": "^6.1.0", 64 | "supertest": "~0.13.0" 65 | }, 66 | "engines": { 67 | "node": "^12.13.0" 68 | }, 69 | "keywords": [ 70 | "json", 71 | "api", 72 | "jsonapi", 73 | "json-api", 74 | "framework", 75 | "rest", 76 | "restful" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /test/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let harvester = require('../lib/harvester'); 3 | let JSONAPI_Error = harvester.JSONAPI_Error; 4 | 5 | let Promise = require('bluebird'); 6 | Promise.longStackTraces(); 7 | 8 | let Joi = require('joi'); 9 | 10 | let config = require('./config.js'); 11 | 12 | function configureApp(harvesterApp) { 13 | harvesterApp 14 | .resource('person', { 15 | name: Joi.string().required().description('name'), 16 | nickname: Joi.string().description('nickname'), 17 | appearances: Joi.number().required().description('appearances'), 18 | links: { 19 | pets: ['pet'], 20 | soulmate: { ref: 'person', inverse: 'soulmate' }, 21 | lovers: [{ ref: 'person', inverse: 'lovers' }], 22 | }, 23 | }) 24 | .resource('vehicle', { 25 | name: Joi.string(), 26 | links: { 27 | owners: [{ ref: 'person' }], 28 | }, 29 | }) 30 | .resource('pet', { 31 | name: Joi.string().required().description('name'), 32 | appearances: Joi.number().required().description('appearances'), 33 | links: { 34 | owner: 'person', 35 | food: 'foobar', 36 | }, 37 | adopted: Joi.date(), 38 | }) 39 | .resource('collar', { 40 | links: { 41 | collarOwner: 'pet', 42 | }, 43 | }) 44 | .resource( 45 | 'cat', 46 | { 47 | name: Joi.string().required().description('name'), 48 | hasToy: Joi.boolean().required().description('hasToy'), 49 | numToys: Joi.number().required().description('numToys'), 50 | }, 51 | { namespace: 'animals' } 52 | ) 53 | .resource('foobar', { 54 | foo: Joi.string().required().description('name'), 55 | }) 56 | .before(function() { 57 | var foobar = this; 58 | 59 | if (foobar.foo && foobar.foo === 'bar') { 60 | // promise 61 | return new Promise(function(resolve, reject) { 62 | reject( 63 | new JSONAPI_Error({ 64 | status: 400, 65 | detail: 'Foo was bar', 66 | }) 67 | ); 68 | }); 69 | } else if (foobar.foo && foobar.foo === 'baz') { 70 | // non-promise 71 | throw new JSONAPI_Error({ 72 | status: 400, 73 | detail: 'Foo was baz', 74 | }); 75 | } else { 76 | return foobar; 77 | } 78 | }) 79 | .resource('readers', { 80 | name: Joi.string().description('name'), 81 | }) 82 | .readOnly() 83 | .resource('restrict', { 84 | name: Joi.string().description('name'), 85 | }) 86 | .restricted() 87 | .resource('immutable', { 88 | name: Joi.string().description('name'), 89 | }) 90 | .immutable() 91 | .resource('object', { 92 | foo: Joi.object().required().keys({ 93 | bar: Joi.string(), 94 | tab: Joi.object().keys({ 95 | bats: Joi.array(), 96 | }), 97 | any: Joi.any(), 98 | }), 99 | }); 100 | 101 | harvesterApp.router.get('/random-error', function(req, res, next) { 102 | next(new Error('this is an error')); 103 | }); 104 | 105 | harvesterApp.router.get('/json-errors-error', function(req, res, next) { 106 | next(new JSONAPI_Error({ status: 400, detail: 'Bar was not foo' })); 107 | }); 108 | 109 | return harvesterApp; 110 | } 111 | 112 | /** 113 | * Creates instance of harvester app with default routes. 114 | * 115 | * This function can be safely passed to before or beforeEach as it will attempt install app and config into mocha's context 116 | * 117 | * beforeEach(require('./app.js')); 118 | * 119 | * @returns {*} promise resolving to harvester app instance 120 | */ 121 | module.exports = function() { 122 | var app = harvester(config.harvester.options); 123 | configureApp(app); 124 | app.listen(config.harvester.port); 125 | this.harvesterApp = app; 126 | this.config = config; 127 | return app; 128 | }; 129 | -------------------------------------------------------------------------------- /test/associations.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var should = require('should'); 3 | var request = require('supertest'); 4 | var uuid = require('node-uuid'); 5 | 6 | var seeder = require('./seeder.js'); 7 | 8 | describe('associations', function() { 9 | var config, ids; 10 | function setupDBForInterdependentTests() { 11 | before(function() { 12 | config = this.config; 13 | return seeder(this.harvesterApp) 14 | .dropCollectionsAndSeed('people', 'pets') 15 | .then(function(_ids) { 16 | ids = _ids; 17 | }); 18 | }); 19 | } 20 | 21 | describe('many to one association', function() { 22 | setupDBForInterdependentTests(); 23 | 24 | it('should be able to associate', function(done) { 25 | var payload = {}; 26 | 27 | payload.people = [ 28 | { 29 | links: { 30 | pets: [ids.pets[0]], 31 | }, 32 | }, 33 | ]; 34 | 35 | request(config.baseUrl) 36 | .put('/people/' + ids.people[0]) 37 | .send(payload) 38 | .expect('Content-Type', /json/) 39 | .expect(200) 40 | .end(function(error, response) { 41 | should.not.exist(error); 42 | var body = JSON.parse(response.text); 43 | body.people[0].links.pets.should.containEql(ids.pets[0]); 44 | done(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('one to many association', function() { 50 | setupDBForInterdependentTests(); 51 | 52 | it('should be able to associate', function(done) { 53 | var payload = {}; 54 | 55 | payload.pets = [ 56 | { 57 | links: { 58 | owner: ids.people[0], 59 | }, 60 | }, 61 | ]; 62 | 63 | request(config.baseUrl) 64 | .put('/pets/' + ids.pets[0]) 65 | .send(payload) 66 | .expect('Content-Type', /json/) 67 | .expect(200) 68 | .end(function(error, response) { 69 | should.not.exist(error); 70 | var body = JSON.parse(response.text); 71 | should.equal(body.pets[0].links.owner, ids.people[0]); 72 | done(); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('one to one association', function() { 78 | setupDBForInterdependentTests(); 79 | 80 | it('should be able to associate', function(done) { 81 | var payload = {}; 82 | 83 | payload.people = [ 84 | { 85 | links: { 86 | soulmate: ids.people[1], 87 | }, 88 | }, 89 | ]; 90 | 91 | request(config.baseUrl) 92 | .put('/people/' + ids.people[0]) 93 | .send(payload) 94 | .expect('Content-Type', /json/) 95 | .expect(200) 96 | .end(function(error, response) { 97 | should.not.exist(error); 98 | var body = JSON.parse(response.text); 99 | should.equal(body.people[0].links.soulmate, ids.people[1]); 100 | done(); 101 | }); 102 | }); 103 | }); 104 | 105 | describe('many to many association', function() { 106 | setupDBForInterdependentTests(); 107 | 108 | it('should be able to associate', function(done) { 109 | var payload = {}; 110 | 111 | payload.people = [ 112 | { 113 | links: { 114 | lovers: [ids.people[1]], 115 | }, 116 | }, 117 | ]; 118 | 119 | request(config.baseUrl) 120 | .put('/people/' + ids.people[0]) 121 | .send(payload) 122 | .expect('Content-Type', /json/) 123 | .expect(200) 124 | .end(function(error, response) { 125 | should.not.exist(error); 126 | var body = JSON.parse(response.text); 127 | body.people[0].links.lovers.should.containEql(ids.people[1]); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | 133 | describe('UUID association', function() { 134 | beforeEach(function() { 135 | config = this.config; 136 | return seeder(this.harvesterApp) 137 | .dropCollectionsAndSeed('people', 'pets') 138 | .then(function(_ids) { 139 | ids = _ids; 140 | }); 141 | }); 142 | 143 | it("shouldn't associate if the property value is a UUID", function(done) { 144 | var payload = {}; 145 | 146 | payload.vehicles = [ 147 | { 148 | id: uuid.v4(), 149 | name: uuid.v4(), 150 | links: { 151 | owners: [uuid.v4()], 152 | }, 153 | }, 154 | ]; 155 | 156 | request(config.baseUrl) 157 | .post('/vehicles') 158 | .send(payload) 159 | .expect('Content-Type', /json/) 160 | .expect(201) 161 | .end(function(error, response) { 162 | should.not.exist(error); 163 | var body = JSON.parse(response.text); 164 | should.not.exist(body.vehicles[0].links.name); 165 | done(); 166 | }); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/authorization.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Joi = require('joi'); 3 | let request = require('supertest'); 4 | let should = require('should'); 5 | let Promise = require('bluebird'); 6 | 7 | let harvester = require('../lib/harvester.js'); 8 | let config = require('./config.js'); 9 | let JSONAPI_Error = require('../lib/jsonapi-error.js'); 10 | let seeder = require('./seeder.js'); 11 | 12 | describe('authorization', function() { 13 | var baseUrl = 'http://localhost:8004'; 14 | var authorizationStrategy; 15 | var harvesterApp; 16 | before(function() { 17 | harvesterApp = harvester(config.harvester.options); 18 | 19 | harvesterApp 20 | .resource('categories', { 21 | name: Joi.string().required().description('a name'), 22 | }) 23 | .resource('products', { 24 | name: Joi.string().required().description('a name'), 25 | }) 26 | .get() 27 | .disableAuthorization() 28 | .register(); 29 | 30 | harvesterApp.setAuthorizationStrategy(function() { 31 | return authorizationStrategy.apply(this, arguments); 32 | }); 33 | 34 | harvesterApp.listen(8004); 35 | }); 36 | 37 | beforeEach(function() { 38 | return seeder(harvesterApp).dropCollections('categories', 'products'); 39 | }); 40 | 41 | describe('when authorizationStrategy returns rejected promise', function() { 42 | beforeEach(function() { 43 | authorizationStrategy = function() { 44 | return Promise.reject(); 45 | }; 46 | }); 47 | it('should return 403 status code', function(done) { 48 | request(baseUrl) 49 | .get('/categories') 50 | .expect('Content-Type', /json/) 51 | .expect(403) 52 | .end(done); 53 | }); 54 | it('should return 200 status code and forward request to resource when authorization is disabled for that endpoint', function( 55 | done 56 | ) { 57 | request(baseUrl) 58 | .get('/products') 59 | .expect('Content-Type', /json/) 60 | .expect(200) 61 | .end(done); 62 | }); 63 | }); 64 | 65 | describe('when authorizationStrategy returns custom JSONAPI_Error', function() { 66 | beforeEach(function() { 67 | authorizationStrategy = function() { 68 | return new JSONAPI_Error({ status: 403 }); 69 | }; 70 | }); 71 | it('should return the same status code', function(done) { 72 | request(baseUrl) 73 | .get('/categories') 74 | .expect('Content-Type', /json/) 75 | .expect(403) 76 | .end(done); 77 | }); 78 | }); 79 | 80 | describe('when authorizationStrategy throws error', function() { 81 | beforeEach(function() { 82 | authorizationStrategy = function() { 83 | return new Error(); 84 | }; 85 | }); 86 | it('should return 500 status code', function(done) { 87 | request(baseUrl) 88 | .get('/categories') 89 | .expect('Content-Type', /json/) 90 | .expect(500) 91 | .end(done); 92 | }); 93 | }); 94 | 95 | describe('when authorizationStrategy throws JSONAPI_Error', function() { 96 | beforeEach(function() { 97 | authorizationStrategy = function() { 98 | return new JSONAPI_Error({ status: 403 }); 99 | }; 100 | }); 101 | it('should return the same status code', function(done) { 102 | request(baseUrl) 103 | .get('/categories') 104 | .expect('Content-Type', /json/) 105 | .expect(403) 106 | .end(done); 107 | }); 108 | }); 109 | 110 | describe('when authorizationStrategy returns JSONAPI_Error promise', function() { 111 | beforeEach(function() { 112 | authorizationStrategy = function() { 113 | return Promise.resolve(new JSONAPI_Error({ status: 499 })); 114 | }; 115 | }); 116 | it('should return the same status code', function(done) { 117 | request(baseUrl) 118 | .get('/categories') 119 | .expect('Content-Type', /json/) 120 | .expect(499) 121 | .end(done); 122 | }); 123 | }); 124 | 125 | describe('when authorizationStrategy returns Error promise', function() { 126 | beforeEach(function() { 127 | authorizationStrategy = function() { 128 | return Promise.resolve(new Error()); 129 | }; 130 | }); 131 | it('should return the same status code', function(done) { 132 | request(baseUrl) 133 | .get('/categories') 134 | .expect('Content-Type', /json/) 135 | .expect(500) 136 | .end(done); 137 | }); 138 | }); 139 | 140 | describe('when authorizationStrategy returns resolved promise', function() { 141 | beforeEach(function() { 142 | authorizationStrategy = function() { 143 | return Promise.resolve(); 144 | }; 145 | }); 146 | it('should return 200 status code and forward request to resource', function( 147 | done 148 | ) { 149 | request(baseUrl) 150 | .get('/categories') 151 | .expect('Content-Type', /json/) 152 | .expect(200) 153 | .end(function(error, response) { 154 | should.not.exist(error); 155 | var body = JSON.parse(response.text); 156 | body.should.eql({ categories: [] }); 157 | done(); 158 | }); 159 | }); 160 | }); 161 | 162 | describe('when authorizationStrategy returns JSONAPI_Error', function() { 163 | beforeEach(function() { 164 | authorizationStrategy = function() { 165 | return new JSONAPI_Error({ status: 444 }); 166 | }; 167 | }); 168 | it('should return status code of the JSONAPI_Error', function(done) { 169 | request(baseUrl) 170 | .get('/categories') 171 | .expect('Content-Type', /json/) 172 | .expect(444) 173 | .end(done); 174 | }); 175 | }); 176 | 177 | describe('when authorizationStrategy throws JSONAPI_Error', function() { 178 | beforeEach(function() { 179 | authorizationStrategy = function() { 180 | throw new JSONAPI_Error({ status: 445 }); 181 | }; 182 | }); 183 | it('should return status code of the JSONAPI_Error', function(done) { 184 | request(baseUrl) 185 | .get('/categories') 186 | .expect('Content-Type', /json/) 187 | .expect(445) 188 | .end(done); 189 | }); 190 | }); 191 | 192 | describe('when authorizationStrategy throws Error', function() { 193 | beforeEach(function() { 194 | authorizationStrategy = function() { 195 | throw new Error('A sample error'); 196 | }; 197 | }); 198 | it('should return 500 status code', function(done) { 199 | request(baseUrl) 200 | .get('/categories') 201 | .expect('Content-Type', /json/) 202 | .expect(500) 203 | .end(done); 204 | }); 205 | }); 206 | 207 | describe('when authorization is disabled only for get resource collection and authorizationStrategy returns resolved promise only for POST product', function() { 208 | beforeEach(function() { 209 | authorizationStrategy = function(request, permission) { 210 | if (permission === 'products.post') { 211 | return Promise.resolve(); 212 | } 213 | return Promise.reject(); 214 | }; 215 | }); 216 | it('should return 200 status code on get collection and forward request to resource', function( 217 | done 218 | ) { 219 | request(baseUrl) 220 | .get('/products') 221 | .expect('Content-Type', /json/) 222 | .expect(200) 223 | .end(function(error, response) { 224 | should.not.exist(error); 225 | var body = JSON.parse(response.text); 226 | body.should.eql({ products: [] }); 227 | done(); 228 | }); 229 | }); 230 | it('should return 403 status code on get single resource', function(done) { 231 | request(baseUrl) 232 | .get('/products/1') 233 | .expect('Content-Type', /json/) 234 | .expect(403) 235 | .end(done); 236 | }); 237 | it('should return 201 status code on post single resource', function(done) { 238 | request(baseUrl) 239 | .post('/products') 240 | .send({ 241 | products: [{ name: 'Pad' }], 242 | }) 243 | .expect('Content-Type', /json/) 244 | .expect(201) 245 | .end(done); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/bodyParserConfiguration.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | let expect = require('chai').expect; 4 | let supertest = require('supertest'); 5 | let harvester = require('../lib/harvester'); 6 | let config = require('./config.js'); 7 | 8 | function configureApp(options, port) { 9 | var harvesterApp = harvester(options); 10 | harvesterApp.router.post('/hugebody', function(req, res) { 11 | res.send(); 12 | }); 13 | harvesterApp.listen(port); 14 | return harvesterApp; 15 | } 16 | 17 | describe('bodyParser configuration', function() { 18 | var payload100kb; 19 | var payload200kb; 20 | var payload300kb; 21 | 22 | before(function() { 23 | var surroundingJsonStringLength = 8; 24 | payload100kb = JSON.stringify({ 25 | a: _.fill(new Array(100000 - surroundingJsonStringLength), 'a').join(''), 26 | }); 27 | payload200kb = JSON.stringify({ 28 | a: _.fill(new Array(200000 - surroundingJsonStringLength), 'a').join(''), 29 | }); 30 | payload300kb = JSON.stringify({ 31 | a: _.fill(new Array(300000 - surroundingJsonStringLength), 'a').join(''), 32 | }); 33 | expect(Buffer.byteLength(payload100kb)).to.equal(100000); 34 | expect(Buffer.byteLength(payload200kb)).to.equal(200000); 35 | expect(Buffer.byteLength(payload300kb)).to.equal(300000); 36 | }); 37 | 38 | describe('when body parser configuration is not provided in options', function() { 39 | var baseUrl; 40 | 41 | before(function() { 42 | var options = _.cloneDeep(config.harvester.options); 43 | delete options.bodyParser; 44 | var port = 8002; 45 | this.harvesterApp = configureApp(options, port); 46 | baseUrl = 'http://localhost:' + port; 47 | }); 48 | 49 | describe('and request payload is 100kb', function() { 50 | it('should respond with 200', function(done) { 51 | supertest(baseUrl) 52 | .post('/hugebody') 53 | .set('Content-type', 'application/json') 54 | .send(payload100kb) 55 | .expect(200) 56 | .end(done); 57 | }); 58 | }); 59 | 60 | describe('and request payload is above 100kb', function() { 61 | it('should respond with 413 entity too large', function(done) { 62 | supertest(baseUrl) 63 | .post('/hugebody') 64 | .set('Content-type', 'application/json') 65 | .send(payload200kb) 66 | .expect(413) 67 | .end(done); 68 | }); 69 | }); 70 | }); 71 | describe('when body parser configuration is provided in options with limit set to 200kb', function() { 72 | var baseUrl; 73 | 74 | before(function() { 75 | var options = _.cloneDeep(config.harvester.options); 76 | options.bodyParser = { limit: '200kb' }; 77 | var port = 8003; 78 | this.harvesterApp = configureApp(options, port); 79 | baseUrl = 'http://localhost:' + port; 80 | }); 81 | 82 | describe('and request payload is 200kb', function() { 83 | it('should respond with 200', function(done) { 84 | supertest(baseUrl) 85 | .post('/hugebody') 86 | .set('Content-type', 'application/json') 87 | .send(payload200kb) 88 | .expect(200) 89 | .end(done); 90 | }); 91 | }); 92 | describe('and request payload is above 200kb', function() { 93 | it('should respond with 413 entity too large', function(done) { 94 | supertest(baseUrl) 95 | .post('/hugebody') 96 | .set('Content-type', 'application/json') 97 | .send(payload300kb) 98 | .expect(413) 99 | .end(done); 100 | }); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/chaining.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let Joi = require('joi'); 4 | 5 | let seeder = require('./seeder.js'); 6 | 7 | describe('chaining', function() { 8 | beforeEach(function() { 9 | return seeder(this.harvesterApp).dropCollectionsAndSeed('people', 'pets'); 10 | }); 11 | describe('resource returns chainable functions', function() { 12 | it('should return httpMethods on last resource', function(done) { 13 | var plant = this.harvesterApp.resource('plant', { 14 | name: Joi.string().required().description('name'), 15 | appearances: Joi.string().required().description('appearances'), 16 | links: { 17 | pets: ['pet'], 18 | soulmate: { ref: 'person', inverse: 'soulmate' }, 19 | lovers: [{ ref: 'person', inverse: 'lovers' }], 20 | }, 21 | }); 22 | 23 | [ 24 | 'get', 25 | 'post', 26 | 'put', 27 | 'delete', 28 | 'getById', 29 | 'putById', 30 | 'deleteById', 31 | 'getChangeEventsStreaming', 32 | ].forEach(function(httpMethod) { 33 | should.exist(plant[httpMethod]().before); 34 | should.exist(plant[httpMethod]().after); 35 | should.exist(plant[httpMethod]().disableAuthorization); 36 | }); 37 | 38 | done(); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/checkpoint-writer.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const checkpointWriter = require('../lib/events-reader-checkpoint-writer'); 5 | const Promise = require('bluebird'); 6 | 7 | describe('checkpoint writer', function() { 8 | describe('timeout', function() { 9 | context('when using the default config options', () => { 10 | const harvestApp = { 11 | adapter: { 12 | update: () => {}, 13 | }, 14 | }; 15 | const fakeDoc = { ts: 1 }; 16 | const checkpointEventEmitter = checkpointWriter.checkpointEventEmitter; 17 | 18 | let clock; 19 | 20 | beforeEach(() => { 21 | clock = sinon.useFakeTimers(); 22 | sinon.stub(harvestApp.adapter, 'update'); 23 | harvestApp.adapter.update.returns(new Promise.resolve(fakeDoc)); 24 | checkpointWriter.startWriterLoop(harvestApp); 25 | checkpointWriter.setWriterLoopStopped(true); 26 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 27 | }); 28 | 29 | afterEach(() => { 30 | harvestApp.adapter.update.restore(); 31 | clock.restore(); 32 | }); 33 | 34 | it('should clean last doc and checkpoint after handled', done => { 35 | clock.tick(1); 36 | expect(harvestApp.adapter.update.callCount).to.be.eql(1); 37 | clock.tick(1); 38 | expect(checkpointWriter.getLastDoc()).to.be.undefined; 39 | expect(checkpointWriter.getLastCheckpointId()).to.be.undefined; 40 | expect(harvestApp.adapter.update.calledOnce).to.be.true; 41 | 42 | done(); 43 | }); 44 | 45 | it('should write a checkpoint in a given interval', done => { 46 | clock.tick(1); 47 | expect(harvestApp.adapter.update.callCount).to.be.eql(1); 48 | 49 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 50 | clock.tick(1); 51 | expect(harvestApp.adapter.update.callCount).to.be.eql(2); 52 | 53 | done(); 54 | }); 55 | 56 | it('should debounce excessive checkpoint update function calls', done => { 57 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 58 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 59 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 60 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 61 | clock.tick(1); 62 | expect(harvestApp.adapter.update.callCount).to.be.eql(1); 63 | 64 | done(); 65 | }); 66 | }); 67 | 68 | context('when passing the option eventsReaderDebounceWait', () => { 69 | const eventsReaderDebounceDelay = 1000; 70 | const harvestApp = { 71 | adapter: { 72 | update: () => {}, 73 | }, 74 | options: { 75 | eventsReaderDebounceWait: eventsReaderDebounceDelay, 76 | }, 77 | }; 78 | const fakeDoc = { ts: 1 }; 79 | const checkpointEventEmitter = checkpointWriter.checkpointEventEmitter; 80 | 81 | let clock; 82 | 83 | beforeEach(() => { 84 | clock = sinon.useFakeTimers(); 85 | sinon.stub(harvestApp.adapter, 'update'); 86 | harvestApp.adapter.update.returns(new Promise.resolve(fakeDoc)); 87 | checkpointWriter.startWriterLoop(harvestApp); 88 | checkpointWriter.setWriterLoopStopped(true); 89 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 90 | }); 91 | 92 | afterEach(() => { 93 | harvestApp.adapter.update.restore(); 94 | clock.restore(); 95 | }); 96 | 97 | it('should write a checkpoint in a given interval', done => { 98 | clock.tick(eventsReaderDebounceDelay); 99 | expect(harvestApp.adapter.update.callCount).to.be.eql(1); 100 | 101 | checkpointEventEmitter.emit('newCheckpoint', 1, fakeDoc); 102 | clock.tick(eventsReaderDebounceDelay); 103 | expect(harvestApp.adapter.update.callCount).to.be.eql(2); 104 | 105 | done(); 106 | }); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let harvesterPort = process.env.HARVESTER_PORT || 8000; 3 | 4 | module.exports = { 5 | baseUrl: 'http://localhost:' + harvesterPort, 6 | harvester: { 7 | port: harvesterPort, 8 | options: { 9 | adapter: 'mongodb', 10 | connectionString: process.env.MONGODB_URL || 11 | 'mongodb://127.0.0.1:27017/test', 12 | db: process.env.MONGODB || 'test', 13 | inflect: true, 14 | oplogConnectionString: (process.env.OPLOG_MONGODB_URL || 15 | 'mongodb://127.0.0.1:27017/local') + '?slaveOk=true', 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /test/customHarvesterInstance.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let request = require('supertest'); 3 | let should = require('should'); 4 | let Joi = require('joi'); 5 | let harvester = require('../lib/harvester'); 6 | 7 | let config = require('./config.js'); 8 | 9 | let seeder = require('./seeder.js'); 10 | 11 | /** 12 | * This test case demonstrates how to setup test with custom harvester on different port 13 | */ 14 | describe('Custom harvester demo', function() { 15 | var baseUrl = 'http://localhost:8001'; 16 | before(function() { 17 | var app = harvester(config.harvester.options); 18 | app.resource('pets', { 19 | name: Joi.string(), 20 | }); 21 | app.listen(8001); 22 | this.harvesterApp = app; 23 | }); 24 | 25 | beforeEach(function() { 26 | return seeder(this.harvesterApp).dropCollectionsAndSeed('pets'); 27 | }); 28 | 29 | it('should hit custom resource', function(done) { 30 | request(baseUrl) 31 | .get('/pets') 32 | .expect('Content-Type', /json/) 33 | .expect(200) 34 | .end(function(error, response) { 35 | should.not.exist(error); 36 | var body = JSON.parse(response.text); 37 | body.pets.length.should.equal(3); 38 | done(); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/deletes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let $http = require('http-as-promised'); 3 | 4 | let seeder = require('./seeder.js'); 5 | 6 | describe('deletes', function() { 7 | var config, ids; 8 | beforeEach(function() { 9 | config = this.config; 10 | return seeder(this.harvesterApp) 11 | .dropCollectionsAndSeed('people', 'pets') 12 | .then(function(_ids) { 13 | ids = _ids; 14 | }); 15 | }); 16 | 17 | it('Should handle deletes with a 204 statusCode', function() { 18 | return $http 19 | .del(config.baseUrl + '/people/' + ids.people[0], { json: {} }) 20 | .spread(function(res) { 21 | res.statusCode.should.equal(204); 22 | delete ids.people[0]; 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/events-reader.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | let Promise = require('bluebird'); 4 | let sinon = require('sinon'); 5 | 6 | let harvesterPort = 8007; 7 | let reportAPI_baseUri = 'http://localhost:9988'; 8 | 9 | let nock = require('nock'); 10 | let config = require('./config.js'); 11 | 12 | let chai = require('chai'); 13 | 14 | let chaiHttp = require('chai-http'); 15 | chai.use(chaiHttp); 16 | chai.request.addPromises(Promise); 17 | 18 | let $http = require('http-as-promised'); 19 | 20 | $http.debug = true; 21 | 22 | let debug = require('debug')('events-reader-test'); 23 | 24 | let expect = chai.expect; 25 | 26 | let harvester = require('../lib/harvester'); 27 | 28 | let Joi = require('joi'); 29 | 30 | let createReportPromise; 31 | let createReportResponseDfd; 32 | 33 | const mongoose = require('mongoose'); 34 | 35 | // todo checkpoints, todo check skipping 36 | 37 | describe.skip('onChange callback, event capture and at-least-once delivery semantics', function() { 38 | var harvesterApp; 39 | var petOnInsertHandler; 40 | 41 | describe( 42 | 'Given a post on a very controversial topic, ' + 43 | 'and a new comment is posted or updated with content which contains profanity, ' + 44 | 'the comment is reported as abusive to another API. ', 45 | function() { 46 | before(function(done) { 47 | petOnInsertHandler = sinon.stub().returnsArg(0); 48 | 49 | var that = this; 50 | that.timeout(10000); 51 | 52 | harvesterApp = harvester(config.harvester.options) 53 | .resource('post', { 54 | title: Joi.string(), 55 | }) 56 | .onChange({ 57 | delete: function() { 58 | console.log('deleted a post'); 59 | }, 60 | }) 61 | .resource('comment', { 62 | body: Joi.string(), 63 | links: { 64 | post: 'post', 65 | }, 66 | }) 67 | .onChange({ 68 | insert: { func: reportAbusiveLanguage }, 69 | update: reportAbusiveLanguage, 70 | }) 71 | .resource('petshop', { 72 | name: Joi.string(), 73 | }) 74 | .resource('pet', { 75 | body: Joi.string(), 76 | }) 77 | .onChange({ 78 | insert: petOnInsertHandler, 79 | }) 80 | .resource('frog', { 81 | body: Joi.string(), 82 | }) 83 | .onChange({ 84 | insert: petOnInsertHandler, 85 | asyncInMemory: true, 86 | }); 87 | 88 | that.chaiExpress = chai.request(harvesterApp.router); 89 | 90 | var profanity = require('profanity-util'); 91 | 92 | function reportAbusiveLanguage(id) { 93 | return harvesterApp.adapter 94 | .find('comment', id.toString()) 95 | .then(function(comment) { 96 | var check = profanity.check(comment); 97 | if (!!check && check.length > 0) { 98 | return ( 99 | $http({ 100 | uri: reportAPI_baseUri + '/reports', 101 | method: 'POST', 102 | json: { 103 | reports: [ 104 | { 105 | content: comment.body, 106 | }, 107 | ], 108 | }, 109 | }) 110 | // then catch handlers below are added to be able to assert results 111 | // this is not common for production code 112 | .spread(function(response) { 113 | createReportResponseDfd.resolve(response); 114 | }) 115 | ); 116 | } 117 | return false; 118 | }); 119 | } 120 | 121 | harvesterApp.listen(harvesterPort); 122 | done(); 123 | }); 124 | 125 | beforeEach(function() { 126 | var that = this; 127 | that.timeout(10000); 128 | 129 | createReportResponseDfd = Promise.defer(); 130 | createReportPromise = createReportResponseDfd.promise; 131 | 132 | var oplogMongodbUri = config.harvester.options.oplogConnectionString; 133 | 134 | that.checkpointCreated = harvesterApp 135 | .eventsReader(oplogMongodbUri) 136 | .then(function(EventsReader) { 137 | that.eventsReader = new EventsReader(); 138 | }) 139 | .then(function() { 140 | return removeModelsData(harvesterApp, [ 141 | 'checkpoint', 142 | 'post', 143 | 'comment', 144 | 'pet', 145 | 'petshop', 146 | 'frog', 147 | ]); 148 | }) 149 | .then(function() { 150 | return initFromLastCheckpoint(harvesterApp); 151 | }).catch(err => { 152 | console.log(err); 153 | }); 154 | 155 | // todo check this with Stephen 156 | // seeder dropCollections doesn't seem to actually remove the data from checkpoints 157 | // fabricated this function as a quick fix 158 | function removeModelsData(harvesterApp, models) { 159 | function removeModelData(model) { 160 | return new Promise(function(resolve, reject) { 161 | harvesterApp.adapter 162 | .model(model) 163 | .collection.remove(function(err, result) { 164 | if (err) { 165 | reject(err); 166 | } 167 | resolve(result); 168 | }); 169 | }); 170 | } 171 | 172 | return Promise.all(_.map(models, removeModelData)); 173 | } 174 | 175 | var initFromLastCheckpoint = function(harvesterApp) { 176 | const oplog = mongoose.connection.db.collection('oplog.rs'); 177 | const Timestamp = mongoose.mongo.Timestamp; 178 | 179 | return new Promise(function(resolve, reject) { 180 | return oplog 181 | .find({}) 182 | .sort({ ts: -1 }) 183 | .limit(1) 184 | .each(function(err, docs) { 185 | if (err) { 186 | reject(err); 187 | } else { 188 | resolve(docs); 189 | } 190 | }); 191 | }).then(function(results) { 192 | var lastTs; 193 | if (results[0]) { 194 | console.log('previous checkpoint found'); 195 | lastTs = results[0].ts; 196 | } else { 197 | console.log('no previous checkpoint found'); 198 | lastTs = Timestamp(0, 1); 199 | } 200 | 201 | // todo make available as a seperate function 202 | function logTs(ts) { 203 | console.log( 204 | 'creating checkpoint with ts ' + 205 | ts.getHighBits() + 206 | ' ' + 207 | ts.getLowBits() + 208 | ' ' + 209 | new Date(ts.getHighBits() * 1000) 210 | ); 211 | } 212 | 213 | logTs(lastTs); 214 | 215 | return harvesterApp.adapter.create('checkpoint', { ts: lastTs }); 216 | }); 217 | }; 218 | 219 | return that.checkpointCreated; 220 | }); 221 | 222 | afterEach(function(done) { 223 | var that = this; 224 | Promise.delay(1000).then(function() { 225 | that.eventsReader 226 | .stop() 227 | .then(function() { 228 | done(); 229 | }) 230 | .catch(done); 231 | }); 232 | }); 233 | 234 | describe('When that abuse report API resource responds with a 201 created', function() { 235 | it('Then the event is considered as handled and should complete successfully with an updated checkpoint', function( 236 | done 237 | ) { 238 | test.call(this, done, function() { 239 | nock(reportAPI_baseUri, { allowUnmocked: true }) 240 | .post('/reports') 241 | .reply(201, function(uri, requestBody) { 242 | return requestBody; 243 | }); 244 | // todo add verify checkpoint 245 | }); 246 | }); 247 | }); 248 | 249 | describe('When that abuse report API resource responds the first time with a 500', function() { 250 | it('Then the event is retried and should complete successfully if the abuse report API responds with a 201 this time', function( 251 | done 252 | ) { 253 | test.call(this, done, function() { 254 | nock(reportAPI_baseUri, { allowUnmocked: true }) 255 | .post('/reports') 256 | .reply(500) 257 | .post('/reports') 258 | .reply(201, function(uri, requestBody) { 259 | return requestBody; 260 | }); 261 | // todo add verify checkpoint 262 | }); 263 | }); 264 | }); 265 | 266 | describe('When pet is inserted', function() { 267 | it('should trigger pet onInsert handler', function(done) { 268 | var that = this; 269 | this.checkpointCreated.then(function() { 270 | setTimeout(that.eventsReader.tail.bind(that.eventsReader), 500); 271 | }); 272 | petOnInsertHandler.reset(); 273 | this.chaiExpress 274 | .post('/pets') 275 | .send({ 276 | pets: [ 277 | { 278 | body: 'Dogbert', 279 | }, 280 | ], 281 | }) 282 | .then(function(res) { 283 | expect(res).to.have.status(201); 284 | return Promise.delay(1000).then(function() { 285 | sinon.assert.calledOnce(petOnInsertHandler); 286 | done(); 287 | }); 288 | }) 289 | .catch(done); 290 | }); 291 | }); 292 | 293 | describe('When petshop is inserted', function() { 294 | it('should NOT trigger pet onInsert handler', function(done) { 295 | var that = this; 296 | this.checkpointCreated.then(function() { 297 | setTimeout(that.eventsReader.tail.bind(that.eventsReader), 500); 298 | }); 299 | petOnInsertHandler.reset(); 300 | this.chaiExpress 301 | .post('/petshops') 302 | .send({ 303 | petshops: [ 304 | { 305 | name: 'Petoroso', 306 | }, 307 | ], 308 | }) 309 | .then(function(res) { 310 | expect(res).to.have.status(201); 311 | return Promise.delay(1000).then(function() { 312 | sinon.assert.notCalled(petOnInsertHandler); 313 | done(); 314 | }); 315 | }) 316 | .catch(done); 317 | }); 318 | }); 319 | 320 | // not a very meaningful test but will have to do for now 321 | describe('When a post is added 10000 times', function() { 322 | it('should process very fast', function(done) { 323 | var that = this; 324 | that.timeout(10000); 325 | 326 | that.checkpointCreated.then(function() { 327 | setTimeout(that.eventsReader.tail.bind(that.eventsReader), 500); 328 | }); 329 | 330 | var range = _.range(10000); 331 | var postPromises = Promise.resolve(range).map( 332 | function(i) { 333 | return that.chaiExpress.post('/frogs').send({ 334 | frogs: [ 335 | { 336 | body: i + ' test', 337 | }, 338 | ], 339 | }); 340 | }, 341 | { concurrency: 20 } 342 | ); 343 | 344 | Promise.all(postPromises) 345 | .then(function() { 346 | console.log('all posted'); 347 | setTimeout(done, 3000); 348 | }) 349 | .catch(function(err) { 350 | console.trace(err); 351 | done(err); 352 | }); 353 | }); 354 | }); 355 | } 356 | ); 357 | 358 | function test(done, mockReports) { 359 | var that = this; 360 | that.timeout(10000); 361 | 362 | mockReports(); 363 | 364 | that.checkpointCreated.then(function() { 365 | setTimeout(that.eventsReader.tail.bind(that.eventsReader), 500); 366 | }); 367 | 368 | that.chaiExpress 369 | .post('/posts') 370 | .send({ 371 | posts: [ 372 | { 373 | title: 'a very controversial topic', 374 | }, 375 | ], 376 | }) 377 | .then(function(postResponse) { 378 | expect(postResponse).to.have.status(201); 379 | return that.chaiExpress 380 | .post('/comments') 381 | .send({ 382 | comments: [ 383 | { 384 | body: 'shit ! what are you talking about !?', 385 | links: { 386 | post: postResponse.body.id, 387 | }, 388 | }, 389 | ], 390 | }) 391 | .then(function(commentResponse) { 392 | expect(commentResponse).to.have.status(201); 393 | debug(commentResponse.body); 394 | return createReportPromise.then(function(createReportResponse) { 395 | expect(createReportResponse).to.have.status(201); 396 | done(); 397 | }); 398 | }); 399 | }) 400 | .catch(function(err) { 401 | console.trace(err); 402 | done(err); 403 | }); 404 | } 405 | }); 406 | -------------------------------------------------------------------------------- /test/exportPermissions.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Joi = require('joi'); 3 | let expect = require('chai').expect; 4 | 5 | let harvester = require('../lib/harvester'); 6 | let config = require('./config.js'); 7 | 8 | describe('Export permissions', function() { 9 | var harvesterInstance; 10 | 11 | before(function() { 12 | harvesterInstance = harvester(config.harvester.options); 13 | var resourceSchema = { name: Joi.string() }; 14 | harvesterInstance.resource('person', resourceSchema).readOnly(); 15 | harvesterInstance.resource('pet', resourceSchema); 16 | harvesterInstance.resource('user', resourceSchema).immutable(); 17 | }); 18 | 19 | it('should export 14 permissions, excluding disallowed ones', function() { 20 | var expectedPermissions = [ 21 | 'person.get', 22 | 'person.getById', 23 | 'person.getChangeEventsStreaming', 24 | 'pet.get', 25 | 'pet.post', 26 | 'pet.getById', 27 | 'pet.putById', 28 | 'pet.deleteById', 29 | 'pet.getChangeEventsStreaming', 30 | 'user.get', 31 | 'user.post', 32 | 'user.getById', 33 | 'user.getChangeEventsStreaming', 34 | ]; 35 | var exportedPermissions = harvesterInstance.exportPermissions(); 36 | expect(exportedPermissions).to.eql(expectedPermissions); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/filters.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | let should = require('should'); 4 | let request = require('supertest'); 5 | let Promise = require('bluebird'); 6 | 7 | let seeder = require('./seeder.js'); 8 | 9 | describe('filters', function() { 10 | var config, ids; 11 | beforeEach(function() { 12 | config = this.config; 13 | return seeder(this.harvesterApp) 14 | .dropCollectionsAndSeed('people', 'pets') 15 | .then(function(_ids) { 16 | ids = _ids; 17 | }); 18 | }); 19 | 20 | it('should allow top-level resource filtering for collection routes', function( 21 | done 22 | ) { 23 | request(config.baseUrl) 24 | .get('/people?name=Dilbert') 25 | .expect('Content-Type', /json/) 26 | .expect(200) 27 | .end(function(error, response) { 28 | should.not.exist(error); 29 | var body = JSON.parse(response.text); 30 | body.people.length.should.equal(1); 31 | done(); 32 | }); 33 | }); 34 | it('should allow top-level resource filtering based on empty property', function( 35 | done 36 | ) { 37 | request(config.baseUrl) 38 | .get('/people?nickname=,') 39 | .expect('Content-Type', /json/) 40 | .expect(200) 41 | .end(function(error, response) { 42 | should.not.exist(error); 43 | var body = JSON.parse(response.text); 44 | body.people.length.should.equal(2); 45 | body.people.forEach(function(person) { 46 | person.should.not.have.property('nickname'); 47 | }); 48 | done(); 49 | }); 50 | }); 51 | it('should allow top-level resource filtering based on empty property', function( 52 | done 53 | ) { 54 | request(config.baseUrl) 55 | .get('/people?nickname=Pocahontas,') 56 | .expect('Content-Type', /json/) 57 | .expect(200) 58 | .end(function(error, response) { 59 | should.not.exist(error); 60 | var body = JSON.parse(response.text); 61 | body.people.length.should.equal(3); 62 | _.map(body.people, 'nickname') 63 | .sort() 64 | .should.eql(['Pocahontas', undefined, undefined]); 65 | done(); 66 | }); 67 | }); 68 | 69 | it('should allow top-level resource filtering based on a numeric value', function( 70 | done 71 | ) { 72 | request(config.baseUrl) 73 | .get('/people?appearances=1934') 74 | .expect('Content-Type', /json/) 75 | .expect(200) 76 | .end(function(error, response) { 77 | should.not.exist(error); 78 | var body = JSON.parse(response.text); 79 | body.people.length.should.equal(1); 80 | done(); 81 | }); 82 | }); 83 | it('should allow combining top-level resource filtering for collection routes based on string & numeric values', function( 84 | done 85 | ) { 86 | request(config.baseUrl) 87 | .get('/people?name=Dilbert&appearances=3457') 88 | .expect('Content-Type', /json/) 89 | .expect(200) 90 | .end(function(error, response) { 91 | should.not.exist(error); 92 | var body = JSON.parse(response.text); 93 | body.people.length.should.equal(1); 94 | done(); 95 | }); 96 | }); 97 | it.skip( 98 | 'should allow resource sub-document filtering based on a numeric value', 99 | function(done) { 100 | request(config.baseUrl) 101 | .get('/people?links.pets=2') 102 | .end(function(err, res) { 103 | var body = JSON.parse(res.text); 104 | 105 | body.cars.length.should.be.equal(1); 106 | body.cars[0].id.should.be.equal('XYZ890'); 107 | done(); 108 | }); 109 | } 110 | ); 111 | it.skip( 112 | 'should be possible to filter related resources by ObjectId', 113 | function(done) { 114 | var cmd = [ 115 | { 116 | op: 'replace', 117 | path: '/people/0/pets', 118 | value: [ids.pets[0], ids.pets[1]], 119 | }, 120 | ]; 121 | // Give a man a pet 122 | request(config.baseUrl) 123 | .patch('/people/' + ids.people[0]) 124 | .set('Content-Type', 'application/vnd.api+json') 125 | .send(JSON.stringify(cmd)) 126 | .expect(200) 127 | .end(function(err) { 128 | should.not.exist(err); 129 | request(config.baseUrl) 130 | .get('/people?filter[pets]=' + ids.pets[0]) 131 | .expect(200) 132 | .end(function(err, res) { 133 | should.not.exist(err); 134 | var data = JSON.parse(res.text); 135 | data.people.should.be.an.Array; 136 | // Make sure filtering was run by ObjectId 137 | /[0-9a-f]{24}/.test(ids.pets[0]).should.be.ok; 138 | /[0-9a-f]{24}/.test(data.people[0].links.pets[0]).should.be.ok; 139 | done(); 140 | }); 141 | }); 142 | } 143 | ); 144 | it.skip( 145 | 'should support filtering by id for one-to-one relationships', 146 | function(done) { 147 | new Promise(function(resolve) { 148 | var upd = [ 149 | { 150 | op: 'replace', 151 | path: '/people/0/soulmate', 152 | value: ids.people[1], 153 | }, 154 | ]; 155 | request(config.baseUrl) 156 | .patch('/people/' + ids.people[0]) 157 | .set('content-type', 'application/json') 158 | .send(JSON.stringify(upd)) 159 | .expect(200) 160 | .end(function(err) { 161 | should.not.exist(err); 162 | resolve(); 163 | }); 164 | }).then(function() { 165 | request(config.baseUrl) 166 | .get('/people?filter[soulmate]=' + ids.people[1]) 167 | .expect(200) 168 | .end(function(err, res) { 169 | should.not.exist(err); 170 | var body = JSON.parse(res.text); 171 | body.people[0].id.should.equal(ids.people[0]); 172 | done(); 173 | }); 174 | }); 175 | } 176 | ); 177 | it.skip('should support `in` query', function(done) { 178 | new Promise(function(resolve) { 179 | var upd = [ 180 | { 181 | op: 'add', 182 | path: '/people/0/links/houses/-', 183 | value: ids.houses[0], 184 | }, 185 | { 186 | op: 'add', 187 | path: '/people/0/links/houses/-', 188 | value: ids.houses[1], 189 | }, 190 | ]; 191 | request(config.baseUrl) 192 | .patch('/people/' + ids.people[0]) 193 | .set('content-type', 'application/json') 194 | .send(JSON.stringify(upd)) 195 | .expect(200) 196 | .end(function(err) { 197 | should.not.exist(err); 198 | resolve(); 199 | }); 200 | }) 201 | .then(function() { 202 | return new Promise(function(resolve) { 203 | var upd = [ 204 | { 205 | op: 'add', 206 | path: '/people/0/links/houses/-', 207 | value: ids.houses[1], 208 | }, 209 | { 210 | op: 'add', 211 | path: '/people/0/links/houses/-', 212 | value: ids.houses[2], 213 | }, 214 | ]; 215 | request(config.baseUrl) 216 | .patch('/people/' + ids.people[1]) 217 | .set('content-type', 'application/json') 218 | .send(JSON.stringify(upd)) 219 | .expect(200) 220 | .end(function(err) { 221 | should.not.exist(err); 222 | resolve(); 223 | }); 224 | }); 225 | }) 226 | .then(function() { 227 | request(config.baseUrl) 228 | .get( 229 | '/people?filter[houses][in]=' + ids.houses[0] + ',' + ids.houses[1] 230 | ) 231 | .expect(200) 232 | .end(function(err, res) { 233 | should.not.exist(err); 234 | var body = JSON.parse(res.text); 235 | body.people.length.should.equal(2); 236 | done(); 237 | }); 238 | }); 239 | }); 240 | it.skip('should support $in query against one-to-one refs', function(done) { 241 | new Promise(function(resolve) { 242 | request(config.baseUrl) 243 | .patch('/people/robert@mailbert.com') 244 | .set('content-type', 'application/json') 245 | .send( 246 | JSON.stringify([ 247 | { 248 | path: '/people/0/soulmate', 249 | op: 'replace', 250 | value: 'dilbert@mailbert.com', 251 | }, 252 | ]) 253 | ) 254 | .end(function(err) { 255 | should.not.exist(err); 256 | resolve(); 257 | }); 258 | }).then(function() { 259 | request(config.baseUrl) 260 | .get( 261 | '/people?filter[soulmate][$in]=robert@mailbert.com&filter[soulmate][$in]=dilbert@mailbert.com' 262 | ) 263 | .expect(200) 264 | .end(function(err, res) { 265 | should.not.exist(err); 266 | var body = JSON.parse(res.text); 267 | body.people.length.should.equal(2); 268 | done(); 269 | }); 270 | }); 271 | }); 272 | it.skip('should support $in query against many-to-many refs', function(done) { 273 | new Promise(function(resolve) { 274 | request(config.baseUrl) 275 | .patch('/people/robert@mailbert.com') 276 | .set('content-type', 'application/json') 277 | .send( 278 | JSON.stringify([ 279 | { 280 | path: '/people/0/lovers', 281 | op: 'replace', 282 | value: ['dilbert@mailbert.com'], 283 | }, 284 | ]) 285 | ) 286 | .end(function(err) { 287 | should.not.exist(err); 288 | resolve(); 289 | }); 290 | }).then(function() { 291 | request(config.baseUrl) 292 | .get( 293 | '/people?filter[lovers][$in]=robert@mailbert.com&filter[lovers][$in]=dilbert@mailbert.com' 294 | ) 295 | .expect(200) 296 | .end(function(err, res) { 297 | should.not.exist(err); 298 | var body = JSON.parse(res.text); 299 | body.people.length.should.equal(2); 300 | done(); 301 | }); 302 | }); 303 | }); 304 | it.skip('should support $in query against external refs values', function( 305 | done 306 | ) { 307 | new Promise(function(resolve) { 308 | request(config.baseUrl) 309 | .patch('/cars/' + ids.cars[0]) 310 | .set('content-type', 'application/json') 311 | .send( 312 | JSON.stringify([ 313 | { 314 | path: '/cars/0/MOT', 315 | op: 'replace', 316 | value: 'Pimp-my-ride', 317 | }, 318 | ]) 319 | ) 320 | .end(function(err) { 321 | should.not.exist(err); 322 | resolve(); 323 | }); 324 | }).then(function() { 325 | request(config.baseUrl) 326 | .get('/cars?filter[MOT][$in]=Pimp-my-ride') 327 | .expect(200) 328 | .end(function(err, res) { 329 | should.not.exist(err); 330 | var body = JSON.parse(res.text); 331 | body.cars.length.should.equal(1); 332 | done(); 333 | }); 334 | }); 335 | }); 336 | it.skip('should be able to run $in query against nested fields', function( 337 | done 338 | ) { 339 | request(config.baseUrl) 340 | .get('/cars?filter[additionalDetails.seats][in]=2') 341 | .expect(200) 342 | .end(function(err, res) { 343 | should.not.exist(err); 344 | var body = JSON.parse(res.text); 345 | body.cars[0].additionalDetails.seats.should.equal(2); 346 | body.cars.length.should.equal(1); 347 | done(); 348 | }); 349 | }); 350 | it.skip('should be able to run in query against links', function(done) { 351 | new Promise(function(resolve) { 352 | request(config.baseUrl) 353 | .patch('/people/' + ids.people[1]) 354 | .set('content-type', 'application/json') 355 | .send( 356 | JSON.stringify([ 357 | { op: 'replace', path: '/people/0/soulmate', value: ids.people[0] }, 358 | ]) 359 | ) 360 | .end(function(err) { 361 | should.not.exist(err); 362 | resolve(); 363 | }); 364 | }).then(function() { 365 | request(config.baseUrl) 366 | .get( 367 | '/people?filter[soulmate][in]=' + ids.people[0] + ',' + ids.people[1] 368 | ) 369 | .expect(200) 370 | .end(function(err, res) { 371 | should.not.exist(err); 372 | var body = JSON.parse(res.text); 373 | body.people.length.should.equal(2); 374 | done(); 375 | }); 376 | }); 377 | }); 378 | it.skip('should support or query', function(done) { 379 | request(config.baseUrl) 380 | .get('/people?(name=Dilbert|name=Ratbert)&sort=name') 381 | .expect(200) 382 | .end(function(err, res) { 383 | should.not.exist(err); 384 | var body = JSON.parse(res.text); 385 | body.people.length.should.equal(2); 386 | body.people[0].name.should.equal('Dilbert'); 387 | done(); 388 | }); 389 | }); 390 | it('should support lt query', function(done) { 391 | request(config.baseUrl) 392 | .get('/people?appearances=lt=1935') 393 | .expect(200) 394 | .end(function(err, res) { 395 | should.not.exist(err); 396 | var body = JSON.parse(res.text); 397 | body.people.length.should.equal(2); 398 | body.people[0].name.should.equal('Wally'); 399 | done(); 400 | }); 401 | }); 402 | it('should support le query', function(done) { 403 | request(config.baseUrl) 404 | .get('/people?appearances=le=1934') 405 | .expect(200) 406 | .end(function(err, res) { 407 | should.not.exist(err); 408 | var body = JSON.parse(res.text); 409 | body.people.length.should.equal(2); 410 | body.people[0].name.should.equal('Wally'); 411 | done(); 412 | }); 413 | }); 414 | it('should support gt query', function(done) { 415 | request(config.baseUrl) 416 | .get('/people?appearances=gt=1935') 417 | .expect(200) 418 | .end(function(err, res) { 419 | should.not.exist(err); 420 | var body = JSON.parse(res.text); 421 | body.people.length.should.equal(1); 422 | body.people[0].name.should.equal('Dilbert'); 423 | done(); 424 | }); 425 | }); 426 | it('should support ge query', function(done) { 427 | request(config.baseUrl) 428 | .get('/people?appearances=ge=3457') 429 | .expect(200) 430 | .end(function(err, res) { 431 | should.not.exist(err); 432 | var body = JSON.parse(res.text); 433 | body.people.length.should.equal(1); 434 | body.people[0].name.should.equal('Dilbert'); 435 | done(); 436 | }); 437 | }); 438 | it('should have id filter', function(done) { 439 | request(config.baseUrl) 440 | .get('/people?id=' + ids.people[0]) 441 | .expect(200) 442 | .end(function(err, res) { 443 | should.not.exist(err); 444 | var body = JSON.parse(res.text); 445 | body.people[0].id.should.equal(ids.people[0]); 446 | done(); 447 | }); 448 | }); 449 | it('should convert a range query with lt= and gt= into a mongo query object', function( 450 | done 451 | ) { 452 | let testUrl = 453 | '/pets?limit=100&adopted=ge=2015-10-08T18:40:28.000Z&adopted=le=2015-10-16T21:40:28.000Z'; 454 | request(config.baseUrl).get(testUrl).expect(200).end(function(error, res) { 455 | should.not.exist(error); 456 | var body = JSON.parse(res.text); 457 | body.pets.length.should.equal(2); 458 | done(); 459 | }); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /test/fixtures/collars.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [{}]; 3 | })(); 4 | -------------------------------------------------------------------------------- /test/fixtures/foobars.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [ 3 | { 4 | foo: 'bert', 5 | }, 6 | ]; 7 | })(); 8 | -------------------------------------------------------------------------------- /test/fixtures/immutables.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [ 3 | { 4 | id: 'c344d722-b7f9-49dd-9842-f0a375f7dfdc', 5 | name: 'Imbert', 6 | }, 7 | ]; 8 | })(); 9 | -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | let fs = require('fs'); 4 | let path = require('path'); 5 | 6 | function FixturesSync() { 7 | let fixtureList = fs 8 | .readdirSync(path.join(__dirname, './')) 9 | .filter(function(item) { 10 | return item !== 'index.js'; 11 | }); 12 | let fixtures; 13 | 14 | if (!fixtures) { 15 | fixtures = {}; 16 | _.forEach(fixtureList, function A(value) { 17 | fixtures[path.basename(value, '.js')] = require('./' + value); 18 | }); 19 | } 20 | return fixtures; 21 | } 22 | 23 | let standardFixture = FixturesSync(); 24 | 25 | module.exports = function() { 26 | return _.cloneDeep(standardFixture); 27 | }; 28 | -------------------------------------------------------------------------------- /test/fixtures/people.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [ 3 | { 4 | id: 'c344d722-b7f9-49dd-9842-f0a375f7dfdc', 5 | name: 'Dilbert', 6 | appearances: 3457, 7 | }, 8 | { 9 | id: 'df647359-16d7-4771-a256-371b878d7201', 10 | name: 'Wally', 11 | appearances: 1934, 12 | }, 13 | { 14 | id: '600274a7-3862-45d7-8fca-e558cea1cf6d', 15 | name: 'Catbert', 16 | nickname: 'Pocahontas', 17 | appearances: 205, 18 | }, 19 | ]; 20 | })(); 21 | -------------------------------------------------------------------------------- /test/fixtures/pets.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [ 3 | { 4 | name: 'Dogbert', 5 | appearances: 1903, 6 | links: { 7 | owner: 'c344d722-b7f9-49dd-9842-f0a375f7dfdc', 8 | }, 9 | adopted: '2015-10-16T19:40:28.000Z', 10 | }, 11 | { 12 | name: 'Ratbert', 13 | appearances: 509, 14 | links: { 15 | owner: 'df647359-16d7-4771-a256-371b878d7201', 16 | }, 17 | adopted: '2015-10-16T20:40:28.000Z', 18 | }, 19 | { 20 | name: 'Catbert', 21 | appearances: 111, 22 | links: { 23 | owner: 'df647359-16d7-4771-a256-371b878d7201', 24 | }, 25 | adopted: '2015-10-16T23:40:28.000Z', 26 | }, 27 | ]; 28 | })(); 29 | -------------------------------------------------------------------------------- /test/fixtures/readers.js: -------------------------------------------------------------------------------- 1 | module.exports = (function() { 2 | return [ 3 | { 4 | id: 'c344d722-b7f9-49dd-9842-f0a375f7dfdc', 5 | name: 'Rodbert', 6 | }, 7 | ]; 8 | })(); 9 | -------------------------------------------------------------------------------- /test/global.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let app = require('./app.js'); 3 | 4 | before(app); 5 | -------------------------------------------------------------------------------- /test/immutable.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let supertest = require('supertest'); 4 | let seeder = require('./seeder.js'); 5 | 6 | describe('Immutable', function() { 7 | var config; 8 | var ids; 9 | 10 | beforeEach(function() { 11 | config = this.config; 12 | return seeder(this.harvesterApp) 13 | .dropCollectionsAndSeed('immutables') 14 | .then(function(_ids) { 15 | ids = _ids; 16 | }); 17 | }); 18 | 19 | it('should be possible to post', function(done) { 20 | var data = { 21 | immutables: [{ name: 'Jack' }], 22 | }; 23 | supertest(config.baseUrl) 24 | .post('/immutables') 25 | .send(data) 26 | .expect('Content-Type', /json/) 27 | .expect(201) 28 | .end(function(error) { 29 | should.not.exist(error); 30 | done(); 31 | }); 32 | }); 33 | 34 | it('should be possible to get', function(done) { 35 | supertest(config.baseUrl) 36 | .get('/readers') 37 | .expect('Content-Type', /json/) 38 | .expect(200) 39 | .end(function(error) { 40 | should.not.exist(error); 41 | done(); 42 | }); 43 | }); 44 | 45 | it('should be possible to getById', function(done) { 46 | supertest(config.baseUrl) 47 | .get('/immutables/' + ids.immutables[0]) 48 | .expect('Content-Type', /json/) 49 | .expect(200) 50 | .end(function(error) { 51 | should.not.exist(error); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('should NOT be possible to deleteById', function(done) { 57 | supertest(config.baseUrl) 58 | .delete('/immutables/' + ids.immutables[0]) 59 | .expect('Content-Type', /json/) 60 | .expect(405) 61 | .end(function(error) { 62 | should.not.exist(error); 63 | done(); 64 | }); 65 | }); 66 | 67 | it('should NOT be possible to putById', function(done) { 68 | var data = { 69 | immutables: [{ name: 'Duck' }], 70 | }; 71 | supertest(config.baseUrl) 72 | .put('/immutables/' + ids.immutables[0]) 73 | .send(data) 74 | .expect('Content-Type', /json/) 75 | .expect(405) 76 | .end(function(error) { 77 | should.not.exist(error); 78 | done(); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/includes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let _ = require('lodash'); 4 | let request = require('supertest'); 5 | let Promise = require('bluebird'); 6 | 7 | let seeder = require('./seeder.js'); 8 | 9 | describe('includes', function() { 10 | var config, ids; 11 | 12 | function setupLinks(_ids) { 13 | ids = _ids; 14 | 15 | function link(resource, resourceId, link, linkId) { 16 | const url = `/${resource}/${resourceId}`; 17 | 18 | return new Promise(function(resolve) { 19 | var data = { 20 | [resource]: [ 21 | { 22 | links: { 23 | [link]: linkId, 24 | }, 25 | }, 26 | ], 27 | }; 28 | request(config.baseUrl) 29 | .put(url) 30 | .set('Content-Type', 'application/json') 31 | .send(JSON.stringify(data)) 32 | .end(function(err, res) { 33 | res.statusCode.should.be.equal(200); 34 | should.not.exist(err); 35 | setTimeout(resolve, 100); //sometimes tests fail if resolve is called immediately, probably mongo has problems indexing concurrently 36 | }); 37 | }); 38 | } 39 | 40 | return Promise.all([ 41 | link('people', ids.people[0], 'soulmate', ids.people[1]), 42 | link('people', ids.people[1], 'soulmate', ids.people[0]), 43 | link('people', ids.people[0], 'lovers', [ids.people[1]]), 44 | link('pets', ids.pets[0], 'owner', ids.people[0]), 45 | link('pets', ids.pets[0], 'food', ids.foobars[0]), 46 | link('collars', ids.collars[0], 'collarOwner', ids.pets[0]), 47 | ]); 48 | } 49 | 50 | beforeEach(function() { 51 | config = this.config; 52 | return seeder(this.harvesterApp) 53 | .dropCollectionsAndSeed('people', 'pets', 'collars', 'foobars') 54 | .then(setupLinks); 55 | }); 56 | 57 | describe('many to many', function() { 58 | it('should include referenced lovers when querying people', function(done) { 59 | request(config.baseUrl) 60 | .get('/people?include=lovers') 61 | .expect(200) 62 | .end(function(err, res) { 63 | should.not.exist(err); 64 | var body = JSON.parse(res.text); 65 | should.exist(body.linked); 66 | body.linked.should.be.an.Object; 67 | body.linked.people.should.be.an.Array; 68 | body.linked.people.length.should.be.above(0); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | describe('one to one', function() { 74 | it('should include soulmate when querying people', function(done) { 75 | request(config.baseUrl) 76 | .get('/people?include=soulmate') 77 | .expect(200) 78 | .end(function(err, res) { 79 | should.not.exist(err); 80 | var body = JSON.parse(res.text); 81 | body.linked.should.be.an.Object; 82 | body.linked.people.should.be.an.Array; 83 | body.linked.people.length.should.equal(2); 84 | done(); 85 | }); 86 | }); 87 | }); 88 | // Todo: add test for "," support. 89 | 90 | describe('repeated entities', function() { 91 | it('should deduplicate included soulmate & lovers when querying people', function( 92 | done 93 | ) { 94 | request(config.baseUrl) 95 | .get('/people?include=soulmate,lovers') 96 | .expect(200) 97 | .end(function(err, res) { 98 | should.not.exist(err); 99 | var body = JSON.parse(res.text); 100 | body.linked.should.be.an.Object; 101 | body.linked.people.should.be.an.Array; 102 | var log = {}; 103 | _.each(body.linked.people, function(person) { 104 | should.not.exist(log[person.id]); 105 | log[person.id] = person; 106 | }); 107 | done(); 108 | }); 109 | }); 110 | }); 111 | 112 | describe('compound documents', function() { 113 | it('should include pet and person when querying collars', function(done) { 114 | request(config.baseUrl) 115 | .get( 116 | '/collars?include=collarOwner.owner.soulmate,collarOwner.food,collarOwner,collarOwner.owner' 117 | ) 118 | .expect(200) 119 | .end(function(err, res) { 120 | should.not.exist(err); 121 | var body = JSON.parse(res.text); 122 | should.exist(body.linked); 123 | body.linked.should.be.an.Object; 124 | body.linked.pets.should.be.an.Array; 125 | body.linked.pets.length.should.be.equal(1); 126 | body.linked.people.should.be.an.Array; 127 | body.linked.people.length.should.be.equal(2); 128 | body.linked.foobars.should.be.an.Array; 129 | body.linked.foobars.length.should.be.equal(1); 130 | done(); 131 | }); 132 | }); 133 | }); 134 | 135 | describe('empty inclusion array', function() { 136 | it('should NOT throw error', function() { 137 | var includes = require('../lib/includes.js')( 138 | this.harvesterApp, 139 | this.harvesterApp._schema 140 | ); 141 | includes.linked({ people: [] }, []); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/jsonapi_error.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let request = require('supertest'); 4 | 5 | describe('jsonapi error handling', function() { 6 | var config; 7 | beforeEach(function() { 8 | config = this.config; 9 | }); 10 | 11 | describe('raise a JSONAPI_Error error in foobar before callback', function() { 12 | it('should respond with a 400 and content-type set to application/vnd.api+json', function( 13 | done 14 | ) { 15 | request(config.baseUrl) 16 | .post('/foobars') 17 | .send({ 18 | foobars: [{ foo: 'bar' }], 19 | }) 20 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 21 | .expect(400) 22 | .end(function(error, response) { 23 | var body = JSON.parse(response.text); 24 | should.exist(body.errors[0].status); 25 | body.errors[0].status.should.equal(400); 26 | should.exist(body.errors[0].title); 27 | body.errors[0].title.should.equal('Request was malformed.'); 28 | should.exist(body.errors[0].detail); 29 | body.errors[0].detail.should.equal('Foo was bar'); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('return a promise which rejects with a JSONAPI_Error in foobar before callback', function() { 36 | it('should respond with a 400 and content-type set to application/vnd.api+json', function( 37 | done 38 | ) { 39 | request(config.baseUrl) 40 | .post('/foobars') 41 | .send({ 42 | foobars: [{ foo: 'baz' }], 43 | }) 44 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 45 | .expect(400) 46 | .end(function(error, response) { 47 | var body = JSON.parse(response.text); 48 | should.exist(body.errors[0].status); 49 | body.errors[0].status.should.equal(400); 50 | should.exist(body.errors[0].title); 51 | body.errors[0].title.should.equal('Request was malformed.'); 52 | should.exist(body.errors[0].detail); 53 | body.errors[0].detail.should.equal('Foo was baz'); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | 59 | describe('bulk insert 2 entities into /foobars resource, each before callback rejects with an JSONAPI_Error', function() { 60 | it('should respond with a 400, content-type set to application/vnd.api+json and the errors object carries the first error encountered', function( 61 | done 62 | ) { 63 | request(config.baseUrl) 64 | .post('/foobars') 65 | .send({ 66 | foobars: [{ foo: 'bar' }, { foo: 'baz' }], 67 | }) 68 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 69 | .expect(400) 70 | .end(function(error, response) { 71 | var body = JSON.parse(response.text); 72 | should.exist(body.errors[0].status); 73 | body.errors[0].status.should.equal(400); 74 | should.exist(body.errors[0].title); 75 | body.errors[0].title.should.equal('Request was malformed.'); 76 | should.exist(body.errors[0].detail); 77 | done(); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('raise random error in the express req/res chain', function() { 83 | it('should respond with a 500 error and content-type set to application/vnd.api+json', function( 84 | done 85 | ) { 86 | request(config.baseUrl) 87 | .get('/random-error') 88 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 89 | .expect(500) 90 | .end(function(error, response) { 91 | var body = JSON.parse(response.text); 92 | should.exist(body.errors[0].status); 93 | body.errors[0].status.should.equal(500); 94 | should.exist(body.errors[0].title); 95 | body.errors[0].title.should.equal('Oops, something went wrong.'); 96 | should.exist(body.errors[0].detail); 97 | body.errors[0].detail.should.equal('Error: this is an error'); 98 | done(); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('raise JSONAPI_Error with 400 status code in the express the req/res chain', function() { 104 | it('should respond with a 400 error and content-type set to application/vnd.api+json', function( 105 | done 106 | ) { 107 | request(config.baseUrl) 108 | .get('/json-errors-error') 109 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 110 | .expect(400) 111 | .end(function(error, response) { 112 | var body = JSON.parse(response.text); 113 | should.exist(body.errors[0].status); 114 | body.errors[0].status.should.equal(400); 115 | should.exist(body.errors[0].title); 116 | body.errors[0].title.should.equal('Request was malformed.'); 117 | should.exist(body.errors[0].detail); 118 | body.errors[0].detail.should.equal('Bar was not foo'); 119 | done(); 120 | }); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/limits.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let request = require('supertest'); 3 | let should = require('should'); 4 | 5 | let seeder = require('./seeder.js'); 6 | 7 | describe('limits', function() { 8 | var config; 9 | beforeEach(function() { 10 | config = this.config; 11 | return seeder(this.harvesterApp).dropCollectionsAndSeed('people', 'pets'); 12 | }); 13 | 14 | describe('limits', function() { 15 | it('should be possible to tell how many documents to return', function( 16 | done 17 | ) { 18 | request(config.baseUrl) 19 | .get('/people?limit=1') 20 | .expect(200) 21 | .end(function(err, res) { 22 | should.not.exist(err); 23 | var body = JSON.parse(res.text); 24 | body.people.length.should.equal(1); 25 | done(); 26 | }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --ui bdd 2 | --reporter spec 3 | --recursive 4 | test/**/*.spec.js 5 | -------------------------------------------------------------------------------- /test/multiSSE.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let request = require('supertest'); 3 | let harvester = require('../lib/harvester'); 4 | let baseUrl = 'http://localhost:' + 8020; 5 | let chai = require('chai'); 6 | let expect = chai.expect; 7 | let ess = require('agco-event-source-stream'); 8 | let _ = require('lodash'); 9 | let config = require('./config.js'); 10 | let seeder = require('./seeder.js'); 11 | let Joi = require('joi'); 12 | let Promise = require('bluebird'); 13 | 14 | describe('EventSource implementation for multiple resources', function() { 15 | var harvesterApp; 16 | describe('Server Sent Events', function() { 17 | this.timeout(20000); 18 | 19 | var sendAndCheckSSE = function(resources, payloads, done) { 20 | var index = 0; 21 | var eventSource = ess( 22 | baseUrl + '/changes/stream?resources=' + resources.join(','), 23 | { retry: false } 24 | ).on('data', function(res) { 25 | var data = JSON.parse(res.data); 26 | var expectedEventName = resources[index] + 's_i'; 27 | // ignore ticker data 28 | if (_.isNumber(data)) { 29 | // post data after we've hooked into change events and receive a ticker 30 | return Promise.map( 31 | payloads, 32 | function(payload) { 33 | return seeder(harvesterApp, baseUrl).seedCustomFixture(payload); 34 | }, 35 | { concurrency: 1 } 36 | ); 37 | } 38 | 39 | expect(res.event.trim()).to.equal(expectedEventName); 40 | expect(_.omit(data, 'id')).to.deep.equal( 41 | payloads[index][resources[index] + 's'][0] 42 | ); 43 | if (index === payloads.length - 1) { 44 | done(); 45 | eventSource.destroy(); 46 | } 47 | 48 | index++; 49 | }); 50 | }; 51 | 52 | before(function() { 53 | var options = { 54 | adapter: 'mongodb', 55 | connectionString: config.harvester.options.connectionString, 56 | db: 'test', 57 | inflect: true, 58 | oplogConnectionString: config.harvester.options.oplogConnectionString, 59 | }; 60 | 61 | harvesterApp = harvester(options) 62 | .resource('booka', { 63 | name: Joi.string(), 64 | }) 65 | .resource('bookb', { 66 | name: Joi.string(), 67 | }); 68 | harvesterApp.listen(8020); 69 | 70 | return seeder(harvesterApp, baseUrl).dropCollections('bookas', 'bookbs'); 71 | }); 72 | 73 | describe( 74 | 'Given a resources A' + 75 | '\nAND base URL base_url' + 76 | '\nWhen a GET is made to base_url/changes/stream?resources=A', 77 | function() { 78 | it('Then all events for resource A streamed back to the API caller ', function( 79 | done 80 | ) { 81 | var payloads = [ 82 | { 83 | bookas: [ 84 | { 85 | name: 'test name 1', 86 | }, 87 | ], 88 | }, 89 | ]; 90 | sendAndCheckSSE(['booka'], payloads, done); 91 | }); 92 | } 93 | ); 94 | 95 | describe( 96 | 'Given a list of resources A, B, C' + 97 | '\nAND base URL base_url' + 98 | '\nWhen a GET is made to base_url/changes/stream?resources=A,B,C ', 99 | function() { 100 | it('Then all events for resources A, B and C are streamed back to the API caller ', function( 101 | done 102 | ) { 103 | var payloads = [ 104 | { 105 | bookas: [ 106 | { 107 | name: 'test name 1', 108 | }, 109 | ], 110 | }, 111 | { 112 | bookbs: [ 113 | { 114 | name: 'test name 2', 115 | }, 116 | ], 117 | }, 118 | ]; 119 | sendAndCheckSSE(['booka', 'bookb'], payloads, done); 120 | }); 121 | } 122 | ); 123 | 124 | describe( 125 | 'Given a list of resources A, B, C' + 126 | '\nAND base URL base_url' + 127 | '\nWhen a GET is made to base_url/changes/stream?resources=A,B,C ', 128 | function() { 129 | it('Then all events for resources A, B and C are streamed back to the API caller ', function( 130 | done 131 | ) { 132 | var payloads = [ 133 | { 134 | bookas: [ 135 | { 136 | name: 'test name 1', 137 | }, 138 | ], 139 | }, 140 | { 141 | bookbs: [ 142 | { 143 | name: 'test name 2', 144 | }, 145 | ], 146 | }, 147 | ]; 148 | sendAndCheckSSE(['booka', 'bookb'], payloads, done); 149 | }); 150 | } 151 | ); 152 | 153 | describe( 154 | 'Given a list of resources A, B, C' + 155 | '\nAND base URL base_url' + 156 | '\nWhen a GET is made to base_url/changes/stream?resources=A,D ', 157 | function() { 158 | it('Then a 400 HTTP error code and a JSON API error specifying the invalid resource are returned to the API caller ', function( 159 | done 160 | ) { 161 | request(baseUrl) 162 | .get('/changes/stream?resources=booka,wrongResource') 163 | .expect(400) 164 | .expect(function(res) { 165 | var error = JSON.parse(res.text); 166 | expect(error.errors[0].detail).to.equal( 167 | "The follow resources don't exist wrongResource" 168 | ); 169 | }) 170 | .end(done); 171 | }); 172 | } 173 | ); 174 | 175 | describe( 176 | 'Given a list of resources A, B, C' + 177 | '\nAND base URL base_url' + 178 | '\nWhen a GET is made to base_url/changes/stream', 179 | function() { 180 | it('Then a 400 HTTP error code and a JSON API error specifying the invalid resource are returned to the API caller ', function( 181 | done 182 | ) { 183 | request(baseUrl) 184 | .get('/changes/stream') 185 | .expect(400) 186 | .expect(function(res) { 187 | var error = JSON.parse(res.text); 188 | expect(error.errors[0].detail).to.equal( 189 | 'You have not specified any resources, please do so by providing "resource?foo,bar" as query' 190 | ); 191 | }) 192 | .end(done); 193 | }); 194 | } 195 | ); 196 | 197 | describe( 198 | 'Given a list of resources A, B, C' + 199 | '\nAND base URL base_url' + 200 | '\nWhen a GET is made to base_url/changes/stream?resources=A,B ', 201 | function() { 202 | it('Then a 400 HTTP error code and a JSON API error indicating the timestamp is invalid are returned to the API caller. ', function( 203 | done 204 | ) { 205 | request(baseUrl) 206 | .get('/changes/stream?resources=booka,bookb') 207 | .set('Last-Event-ID', '1234567_wrong') 208 | .expect(400) 209 | .expect(function(res) { 210 | var error = JSON.parse(res.text); 211 | expect(error.errors[0].detail).to.equal( 212 | 'Could not parse the time stamp provided' 213 | ); 214 | }) 215 | .end(done); 216 | }); 217 | } 218 | ); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /test/paging.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let _ = require('lodash'); 4 | let request = require('supertest'); 5 | 6 | let seeder = require('./seeder.js'); 7 | 8 | describe('paging', function() { 9 | var config; 10 | beforeEach(function() { 11 | config = this.config; 12 | return seeder(this.harvesterApp).dropCollectionsAndSeed('people', 'pets'); 13 | }); 14 | 15 | it('should be possible to get page 1', function(done) { 16 | request(config.baseUrl) 17 | .get('/people?sort=name&offset=0&limit=1') 18 | .expect(200) 19 | .end(function(err, res) { 20 | should.not.exist(err); 21 | var body = JSON.parse(res.text); 22 | // console.log(body); 23 | body.people.length.should.equal(1); 24 | _.pluck(body.people, 'name').should.eql(['Catbert']); 25 | done(); 26 | }); 27 | }); 28 | 29 | it('should be possible to get page 2', function(done) { 30 | request(config.baseUrl) 31 | .get('/people?sort=name&offset=1&limit=1') 32 | .expect(200) 33 | .end(function(err, res) { 34 | should.not.exist(err); 35 | var body = JSON.parse(res.text); 36 | // console.log(body); 37 | body.people.length.should.equal(1); 38 | _.pluck(body.people, 'name').should.eql(['Dilbert']); 39 | done(); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/readOnly.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let supertest = require('supertest'); 4 | let Joi = require('joi'); 5 | let harvester = require('../lib/harvester'); 6 | let seeder = require('./seeder.js'); 7 | 8 | describe('ReadOnly', function() { 9 | var config; 10 | var ids; 11 | var seedingPort = 8010; 12 | 13 | before(function() { 14 | config = this.config; 15 | var seedingHarvesterInstance = harvester(config.harvester.options); 16 | seedingHarvesterInstance.resource('readers', { 17 | name: Joi.string().description('name'), 18 | }); 19 | seedingHarvesterInstance.listen(seedingPort); 20 | this.seedingHarvesterInstance = seedingHarvesterInstance; 21 | }); 22 | 23 | beforeEach(function() { 24 | return seeder( 25 | this.seedingHarvesterInstance, 26 | 'http://localhost:' + seedingPort 27 | ) 28 | .dropCollectionsAndSeed('readers') 29 | .then(function(_ids) { 30 | ids = _ids; 31 | }); 32 | }); 33 | 34 | it('should NOT be possible to post', function(done) { 35 | var data = { 36 | readers: [{ name: 'Jack' }], 37 | }; 38 | supertest(config.baseUrl) 39 | .post('/readers') 40 | .send(data) 41 | .expect('Content-Type', /json/) 42 | .expect(405) 43 | .end(function(error) { 44 | should.not.exist(error); 45 | done(); 46 | }); 47 | }); 48 | 49 | it('should be possible to get', function(done) { 50 | supertest(config.baseUrl) 51 | .get('/readers') 52 | .expect('Content-Type', /json/) 53 | .expect(200) 54 | .end(function(error) { 55 | should.not.exist(error); 56 | done(); 57 | }); 58 | }); 59 | 60 | it('should be possible to getById', function(done) { 61 | supertest(config.baseUrl) 62 | .get('/readers/' + ids.readers[0]) 63 | .expect('Content-Type', /json/) 64 | .expect(200) 65 | .end(function(error) { 66 | should.not.exist(error); 67 | done(); 68 | }); 69 | }); 70 | 71 | it('should NOT be possible to deleteById', function(done) { 72 | supertest(config.baseUrl) 73 | .delete('/readers/' + ids.readers[0]) 74 | .expect('Content-Type', /json/) 75 | .expect(405) 76 | .end(function(error) { 77 | should.not.exist(error); 78 | done(); 79 | }); 80 | }); 81 | 82 | it('should NOT be possible to putById', function(done) { 83 | var data = { 84 | readers: [{ name: 'Duck' }], 85 | }; 86 | supertest(config.baseUrl) 87 | .put('/readers/' + ids.readers[0]) 88 | .send(data) 89 | .expect('Content-Type', /json/) 90 | .expect(405) 91 | .end(function(error) { 92 | should.not.exist(error); 93 | done(); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/remoteIncludes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let Joi = require('joi'); 3 | let $http = require('http-as-promised'); 4 | let _ = require('lodash'); 5 | let Promise = require('bluebird'); 6 | let request = require('supertest'); 7 | let harvester = require('../lib/harvester'); 8 | 9 | // todo we need a better strategy to stand up test harvesterjs instances 10 | // listening on hard coded free ports and duplicating harvesterjs options is not very robust and DRY 11 | 12 | const harvesterOptions = require('./config').harvester.options; 13 | 14 | describe('remote link', function() { 15 | describe( 16 | "given 2 resources : 'posts', 'people' ; defined on distinct harvesterjs servers " + 17 | "and posts has a remote link 'author' defined to people", 18 | function() { 19 | var app1Port = 8011; 20 | var app2Port = 8012; 21 | var app1BaseUrl = 'http://localhost:' + app1Port; 22 | var app2BaseUrl = 'http://localhost:' + app2Port; 23 | 24 | before(function() { 25 | var that = this; 26 | that.timeout(20000); 27 | 28 | that.harvesterApp1 = harvester(harvesterOptions) 29 | .resource('post', { 30 | title: Joi.string(), 31 | links: { 32 | author: { 33 | ref: 'person', 34 | baseUri: 'http://localhost:' + app2Port, 35 | }, 36 | comments: ['comment'], 37 | topic: 'topic', 38 | }, 39 | }) 40 | .resource('topic', { 41 | name: Joi.string(), 42 | }) 43 | .resource('comment', { 44 | body: Joi.string(), 45 | }) 46 | .listen(app1Port); 47 | 48 | that.harvesterApp2 = harvester(harvesterOptions) 49 | .resource('person', { 50 | firstName: Joi.string(), 51 | lastName: Joi.string(), 52 | links: { 53 | country: 'country', 54 | }, 55 | }) 56 | .resource('country', { 57 | code: Joi.string(), 58 | }) 59 | .listen(app2Port); 60 | 61 | // todo move into utility class or upgrade to latest version of mongoose which returns a promise 62 | function removeCollection(model) { 63 | return new Promise(function(resolve, reject) { 64 | model.collection.remove(function(err, result) { 65 | if (err) { 66 | reject(err); 67 | } 68 | resolve(result); 69 | }); 70 | }); 71 | } 72 | 73 | return removeCollection(that.harvesterApp1.adapter.model('post')) 74 | .then(function() { 75 | return removeCollection(that.harvesterApp2.adapter.model('person')); 76 | }) 77 | .then(function() { 78 | // todo come up with a consistent pattern for seeding 79 | // as far as I can see we are mixing supertest, chai http and http-as-promised 80 | return $http({ 81 | uri: app2BaseUrl + '/countries', 82 | method: 'POST', 83 | json: { countries: [{ code: 'US' }] }, 84 | }); 85 | }) 86 | .spread(function(res, body) { 87 | that.countryId = body.countries[0].id; 88 | return $http({ 89 | uri: app2BaseUrl + '/people', 90 | method: 'POST', 91 | json: { 92 | people: [ 93 | { 94 | firstName: 'Tony', 95 | lastName: 'Maley', 96 | links: { country: that.countryId }, 97 | }, 98 | ], 99 | }, 100 | }); 101 | }) 102 | .spread(function(res, body) { 103 | that.authorId = body.people[0].id; 104 | return $http({ 105 | uri: app1BaseUrl + '/posts', 106 | method: 'POST', 107 | json: { 108 | posts: [ 109 | { title: 'Nodejs rules !', links: { author: that.authorId } }, 110 | ], 111 | }, 112 | }); 113 | }) 114 | .spread(function(res, body) { 115 | that.postId = body.posts[0].id; 116 | return $http({ 117 | uri: app1BaseUrl + '/comments', 118 | method: 'POST', 119 | json: { 120 | comments: [{ body: "That's crazy talk, Ruby is the best !" }], 121 | }, 122 | }); 123 | }) 124 | .spread(function(res, body) { 125 | that.commentId = body.comments[0].id; 126 | return $http({ 127 | uri: app1BaseUrl + '/posts/' + that.postId, 128 | method: 'PUT', 129 | json: { posts: [{ links: { comments: [that.commentId] } }] }, 130 | }); 131 | }); 132 | }); 133 | 134 | describe('fetch posts and include author', function() { 135 | it('should respond with a compound document with people included', function( 136 | done 137 | ) { 138 | var that = this; 139 | // todo come up with a consistent pattern for assertions 140 | request(app1BaseUrl) 141 | .get('/posts?include=author') 142 | .expect(200) 143 | .end(function(error, response) { 144 | var body = response.body; 145 | _.pluck(body.linked.people, 'id').should.eql([that.authorId]); 146 | done(); 147 | }); 148 | }); 149 | }); 150 | 151 | describe('fetch posts include author.country', function() { 152 | it('should respond with a compound document with people and countries included', function( 153 | done 154 | ) { 155 | var that = this; 156 | // todo come up with a consistent pattern for assertions 157 | request(app1BaseUrl) 158 | .get('/posts?include=author.country') 159 | .expect(200) 160 | .end(function(error, response) { 161 | var body = response.body; 162 | _.pluck(body.linked.people, 'id').should.eql([that.authorId]); 163 | _.pluck(body.linked.countries, 'id').should.eql([that.countryId]); 164 | done(); 165 | }); 166 | }); 167 | }); 168 | 169 | describe('fetch posts include topic, author, author.country and comments', function() { 170 | it('should respond with a compound document with people, countries and comments included', function( 171 | done 172 | ) { 173 | var that = this; 174 | // todo come up with a consistent pattern for assertions 175 | request(app1BaseUrl) 176 | .get('/posts?include=topic,comments,author,author.country') 177 | .expect(200) 178 | .end(function(error, response) { 179 | var body = response.body; 180 | _.pluck(body.linked.people, 'id').should.eql([that.authorId]); 181 | _.pluck(body.linked.countries, 'id').should.eql([that.countryId]); 182 | _.pluck(body.linked.comments, 'id').should.eql([that.commentId]); 183 | done(); 184 | }); 185 | }); 186 | }); 187 | } 188 | ); 189 | }); 190 | -------------------------------------------------------------------------------- /test/resources.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let _ = require('lodash'); 4 | let Promise = require('bluebird'); 5 | let request = require('supertest'); 6 | let fixtures = require('./fixtures'); 7 | 8 | let seeder = require('./seeder.js'); 9 | 10 | describe('resources', function() { 11 | var config, ids; 12 | beforeEach(function() { 13 | config = this.config; 14 | return seeder(this.harvesterApp) 15 | .dropCollectionsAndSeed('people', 'pets') 16 | .then(function(_ids) { 17 | ids = _ids; 18 | }); 19 | }); 20 | 21 | describe('getting a list of resources', function() { 22 | _.each(ids, function(resources, key) { 23 | it('in collection "' + key + '"', function(done) { 24 | request(config.baseUrl) 25 | .get('/' + key) 26 | .expect('Content-Type', /json/) 27 | .expect(200) 28 | .end(function(error, response) { 29 | should.not.exist(error); 30 | var body = JSON.parse(response.text); 31 | ids[key].forEach(function(id) { 32 | _.contains(_.pluck(body[key], 'id'), id).should.equal(true); 33 | }); 34 | done(); 35 | }); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('getting each individual resource', function() { 41 | _.each(ids, function(resources, key) { 42 | it('in collection "' + key + '"', function(done) { 43 | Promise.all( 44 | ids[key].map(function(id) { 45 | return new Promise(function(resolve) { 46 | request(config.baseUrl) 47 | .get('/' + key + '/' + id) 48 | .expect('Content-Type', /json/) 49 | .expect(200) 50 | .end(function(error, response) { 51 | should.not.exist(error); 52 | var body = JSON.parse(response.text); 53 | body[key].forEach(function(resource) { 54 | resource.id.should.equal(id); 55 | }); 56 | resolve(); 57 | }); 58 | }); 59 | }) 60 | ).then(function() { 61 | done(); 62 | }); 63 | }); 64 | }); 65 | }); 66 | 67 | describe('posting a duplicate resource', function() { 68 | it("in collection 'people'", function(done) { 69 | var body = { people: [] }; 70 | body.people.push(_.cloneDeep(fixtures().people[0])); 71 | body.people[0].id = ids.people[0]; 72 | Promise.all( 73 | [ids.people[0]].map(function() { 74 | return new Promise(function(resolve) { 75 | request(config.baseUrl) 76 | .post('/people/') 77 | .send(body) 78 | .expect('Content-Type', /json/) 79 | .expect(409) 80 | .end(function(error, response) { 81 | should.not.exist(error); 82 | should.exist(response.error); 83 | resolve(); 84 | }); 85 | }); 86 | }) 87 | ).then(function() { 88 | done(); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('posting a resource with a namespace', function() { 94 | it('should post without a special key', function(done) { 95 | var cat = { 96 | name: 'Spot', 97 | hasToy: true, 98 | numToys: 0, 99 | }, 100 | body = { cats: [] }; 101 | body.cats.push(cat); 102 | request(config.baseUrl) 103 | .post('/animals/cats') 104 | .send(body) 105 | .expect('Content-Type', /json/) 106 | .expect(201) 107 | .end(done); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/restricted.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let supertest = require('supertest'); 4 | 5 | describe('Restricted', function() { 6 | var config; 7 | 8 | before(function() { 9 | config = this.config; 10 | }); 11 | 12 | it('should NOT be possible to post', function(done) { 13 | var data = { 14 | restricts: [{ name: 'Jack' }], 15 | }; 16 | supertest(config.baseUrl) 17 | .post('/restricts') 18 | .send(data) 19 | .expect('Content-Type', /json/) 20 | .expect(405) 21 | .end(function(error) { 22 | should.not.exist(error); 23 | done(); 24 | }); 25 | }); 26 | 27 | it('should not be possible to get', function(done) { 28 | supertest(config.baseUrl) 29 | .get('/restricts') 30 | .expect('Content-Type', /json/) 31 | .expect(405) 32 | .end(function(error) { 33 | should.not.exist(error); 34 | done(); 35 | }); 36 | }); 37 | 38 | it('should not be possible to getById', function(done) { 39 | supertest(config.baseUrl) 40 | .get('/restricts/' + 1) 41 | .expect('Content-Type', /json/) 42 | .expect(405) 43 | .end(function(error) { 44 | should.not.exist(error); 45 | done(); 46 | }); 47 | }); 48 | 49 | it('should NOT be possible to deleteById', function(done) { 50 | supertest(config.baseUrl) 51 | .delete('/restricts/' + 1) 52 | .expect('Content-Type', /json/) 53 | .expect(405) 54 | .end(function(error) { 55 | should.not.exist(error); 56 | done(); 57 | }); 58 | }); 59 | 60 | it('should NOT be possible to putById', function(done) { 61 | var data = { 62 | restricts: [{ name: 'Duck' }], 63 | }; 64 | supertest(config.baseUrl) 65 | .put('/restricts/' + 1) 66 | .send(data) 67 | .expect('Content-Type', /json/) 68 | .expect(405) 69 | .end(function(error) { 70 | should.not.exist(error); 71 | done(); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/roles.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Promise = require('bluebird'); 3 | var request = require('supertest'); 4 | var Joi = require('joi'); 5 | 6 | var harvester = require('../lib/harvester'); 7 | var seeder = require('./seeder.js'); 8 | var config = require('./config.js'); 9 | 10 | describe('Roles', function() { 11 | var harvesterInstance; 12 | var harvesterPort = 8008; 13 | var harvesterSeedPort = 8009; 14 | var seedingHarvesterPort = harvesterPort + 1; 15 | var baseUrl = 'http://localhost:' + harvesterPort; 16 | var seedingBaseUrl = 'http://localhost:' + seedingHarvesterPort; 17 | var personId = '11111111-1111-1111-1111-111111111111'; 18 | var userId = '22222222-2222-2222-2222-222222222222'; 19 | var petId = '33333333-3333-3333-3333-333333333333'; 20 | var authorizationStrategy; 21 | 22 | before(function() { 23 | harvesterInstance = harvester(config.harvester.options); 24 | var resourceSchema = { name: Joi.string() }; 25 | harvesterInstance.resource('person', resourceSchema).roles('Admin'); 26 | harvesterInstance.resource('pets', resourceSchema); 27 | harvesterInstance 28 | .resource('user', resourceSchema) 29 | .roles('Admin', 'SuperAdmin'); 30 | harvesterInstance.resource('user').getById().roles('Moderator'); 31 | harvesterInstance.setAuthorizationStrategy(function() { 32 | return authorizationStrategy.apply(this, arguments); 33 | }); 34 | harvesterInstance.listen(harvesterPort); 35 | 36 | /** 37 | * We need separate instance that does not have roles authorization, so that we can seed freely. 38 | */ 39 | var seedingHarvesterInstance = harvester(config.harvester.options); 40 | seedingHarvesterInstance.resource('person', resourceSchema); 41 | seedingHarvesterInstance.resource('pets', resourceSchema); 42 | seedingHarvesterInstance.resource('user', resourceSchema); 43 | seedingHarvesterInstance.listen(harvesterSeedPort); 44 | this.seedingHarvesterInstance = seedingHarvesterInstance; 45 | }); 46 | beforeEach(function() { 47 | var seederInstance = seeder(this.seedingHarvesterInstance, seedingBaseUrl); 48 | return seederInstance 49 | .dropCollections('people', 'pets', 'users') 50 | .then(function() { 51 | return seederInstance.seedCustomFixture({ 52 | people: [{ id: personId, name: 'Jack' }], 53 | users: [{ id: userId, name: 'Jill' }], 54 | pets: [{ id: petId, name: 'JillDog' }], 55 | }); 56 | }); 57 | }); 58 | 59 | describe('having specific config, exportRoles', function() { 60 | it('should return proper roles descriptor', function() { 61 | var expectedDescriptor = { 62 | Admin: [ 63 | 'person.get', 64 | 'person.post', 65 | 'person.getById', 66 | 'person.putById', 67 | 'person.deleteById', 68 | 'person.getChangeEventsStreaming', 69 | 'user.get', 70 | 'user.post', 71 | 'user.putById', 72 | 'user.deleteById', 73 | 'user.getChangeEventsStreaming', 74 | ], 75 | SuperAdmin: [ 76 | 'user.get', 77 | 'user.post', 78 | 'user.putById', 79 | 'user.deleteById', 80 | 'user.getChangeEventsStreaming', 81 | ], 82 | Moderator: ['user.getById'], 83 | }; 84 | harvesterInstance.exportRoles().should.eql(expectedDescriptor); 85 | }); 86 | }); 87 | 88 | describe('being authed as Admin', function() { 89 | beforeEach(function() { 90 | authorizationStrategy = function(request, permission, rolesAllowed) { 91 | if (rolesAllowed.length === 0 || -1 < rolesAllowed.indexOf('Admin')) { 92 | return Promise.resolve(); 93 | } else { 94 | return Promise.reject(); 95 | } 96 | }; 97 | }); 98 | it('should be allowed to get people', function(done) { 99 | request(baseUrl).get('/people').expect(200).end(done); 100 | }); 101 | it('should be allowed to post person', function(done) { 102 | request(baseUrl) 103 | .post('/people') 104 | .send({ 105 | people: [{ name: 'Steve' }], 106 | }) 107 | .expect(201) 108 | .end(done); 109 | }); 110 | it('should be allowed to get person by id', function(done) { 111 | request(baseUrl).get('/people/' + personId).expect(200).end(done); 112 | }); 113 | it('should be allowed to put person by id', function(done) { 114 | request(baseUrl) 115 | .put('/people/' + personId) 116 | .send({ 117 | people: [{ name: 'Joseph' }], 118 | }) 119 | .expect(200) 120 | .end(done); 121 | }); 122 | it.skip( 123 | 'should be allowed to getChangeEventsStreaming for person', 124 | function() { 125 | throw new Error('Not implemented yet'); 126 | } 127 | ); 128 | 129 | it('should be allowed to get users', function(done) { 130 | request(baseUrl).get('/users').expect(200).end(done); 131 | }); 132 | it('should be allowed to post user', function(done) { 133 | request(baseUrl) 134 | .post('/users') 135 | .send({ 136 | users: [{ name: 'Steve' }], 137 | }) 138 | .expect(201) 139 | .end(done); 140 | }); 141 | it('should NOT be allowed to get user by id', function(done) { 142 | request(baseUrl).get('/users/' + userId).expect(403).end(done); 143 | }); 144 | it('should be allowed to put user by id', function(done) { 145 | request(baseUrl) 146 | .put('/users/' + userId) 147 | .send({ 148 | users: [{ name: 'Joseph' }], 149 | }) 150 | .expect(200) 151 | .end(done); 152 | }); 153 | it.skip( 154 | 'should be allowed to getChangeEventsStreaming for user', 155 | function() { 156 | throw new Error('Not implemented yet'); 157 | } 158 | ); 159 | 160 | it('should be allowed to get pets', function(done) { 161 | request(baseUrl).get('/pets').expect(200).end(done); 162 | }); 163 | it('should be allowed to post pet', function(done) { 164 | request(baseUrl) 165 | .post('/pets') 166 | .send({ 167 | pets: [{ name: 'Steve' }], 168 | }) 169 | .expect(201) 170 | .end(done); 171 | }); 172 | it('should be allowed to get pet by id', function(done) { 173 | request(baseUrl).get('/pets/' + petId).expect(200).end(done); 174 | }); 175 | it('should be allowed to put pet by id', function(done) { 176 | request(baseUrl) 177 | .put('/pets/' + petId) 178 | .send({ 179 | pets: [{ name: 'Joseph' }], 180 | }) 181 | .expect(200) 182 | .end(done); 183 | }); 184 | it.skip( 185 | 'should be allowed to getChangeEventsStreaming for pet', 186 | function() { 187 | throw new Error('Not implemented yet'); 188 | } 189 | ); 190 | }); 191 | 192 | describe('being authed as SuperAdmin', function() { 193 | beforeEach(function() { 194 | authorizationStrategy = function(request, permission, rolesAllowed) { 195 | if ( 196 | rolesAllowed.length === 0 || 197 | -1 < rolesAllowed.indexOf('SuperAdmin') 198 | ) { 199 | return Promise.resolve(); 200 | } else { 201 | return Promise.reject(); 202 | } 203 | }; 204 | }); 205 | it('should NOT be allowed to get people', function(done) { 206 | request(baseUrl).get('/people').expect(403).end(done); 207 | }); 208 | it('should NOT be allowed to post person', function(done) { 209 | request(baseUrl) 210 | .post('/people') 211 | .send({ 212 | people: [{ name: 'Steve' }], 213 | }) 214 | .expect(403) 215 | .end(done); 216 | }); 217 | it('should NOT be allowed to get person by id', function(done) { 218 | request(baseUrl).get('/people/' + personId).expect(403).end(done); 219 | }); 220 | it('should NOT be allowed to put person by id', function(done) { 221 | request(baseUrl) 222 | .put('/people/' + personId) 223 | .send({ 224 | people: [{ name: 'Joseph' }], 225 | }) 226 | .expect(403) 227 | .end(done); 228 | }); 229 | it.skip( 230 | 'should NOT be allowed to getChangeEventsStreaming for person', 231 | function() { 232 | throw new Error('Not implemented yet'); 233 | } 234 | ); 235 | 236 | it('should be allowed to get users', function(done) { 237 | request(baseUrl).get('/users').expect(200).end(done); 238 | }); 239 | it('should be allowed to post user', function(done) { 240 | request(baseUrl) 241 | .post('/users') 242 | .send({ 243 | users: [{ name: 'Steve' }], 244 | }) 245 | .expect(201) 246 | .end(done); 247 | }); 248 | it('should NOT be allowed to get user by id', function(done) { 249 | request(baseUrl).get('/users/' + userId).expect(403).end(done); 250 | }); 251 | it('should be allowed to put user by id', function(done) { 252 | request(baseUrl) 253 | .put('/users/' + userId) 254 | .send({ 255 | users: [{ name: 'Joseph' }], 256 | }) 257 | .expect(200) 258 | .end(done); 259 | }); 260 | it.skip( 261 | 'should be allowed to getChangeEventsStreaming for user', 262 | function() { 263 | throw new Error('Not implemented yet'); 264 | } 265 | ); 266 | 267 | it('should be allowed to get pets', function(done) { 268 | request(baseUrl).get('/pets').expect(200).end(done); 269 | }); 270 | it('should be allowed to post pet', function(done) { 271 | request(baseUrl) 272 | .post('/pets') 273 | .send({ 274 | pets: [{ name: 'Steve' }], 275 | }) 276 | .expect(201) 277 | .end(done); 278 | }); 279 | it('should be allowed to get pet by id', function(done) { 280 | request(baseUrl).get('/pets/' + petId).expect(200).end(done); 281 | }); 282 | it('should be allowed to put pet by id', function(done) { 283 | request(baseUrl) 284 | .put('/pets/' + petId) 285 | .send({ 286 | pets: [{ name: 'Joseph' }], 287 | }) 288 | .expect(200) 289 | .end(done); 290 | }); 291 | it.skip( 292 | 'should be allowed to getChangeEventsStreaming for pet', 293 | function() { 294 | throw new Error('Not implemented yet'); 295 | } 296 | ); 297 | }); 298 | 299 | describe('being authed as Moderator', function() { 300 | beforeEach(function() { 301 | authorizationStrategy = function(request, permission, rolesAllowed) { 302 | if ( 303 | rolesAllowed.length === 0 || 304 | -1 < rolesAllowed.indexOf('Moderator') 305 | ) { 306 | return Promise.resolve(); 307 | } else { 308 | return Promise.reject(); 309 | } 310 | }; 311 | }); 312 | it('should NOT be allowed to get people', function(done) { 313 | request(baseUrl).get('/people').expect(403).end(done); 314 | }); 315 | it('should NOT be allowed to post person', function(done) { 316 | request(baseUrl) 317 | .post('/people') 318 | .send({ 319 | people: [{ name: 'Steve' }], 320 | }) 321 | .expect(403) 322 | .end(done); 323 | }); 324 | it('should NOT be allowed to get person by id', function(done) { 325 | request(baseUrl).get('/people/' + personId).expect(403).end(done); 326 | }); 327 | it('should NOT be allowed to put person by id', function(done) { 328 | request(baseUrl) 329 | .put('/people/' + personId) 330 | .send({ 331 | people: [{ name: 'Joseph' }], 332 | }) 333 | .expect(403) 334 | .end(done); 335 | }); 336 | it.skip( 337 | 'should NOT be allowed to getChangeEventsStreaming for person', 338 | function() { 339 | throw new Error('Not implemented yet'); 340 | } 341 | ); 342 | 343 | it('should NOT be allowed to get users', function(done) { 344 | request(baseUrl).get('/users').expect(403).end(done); 345 | }); 346 | it('should NOT be allowed to post user', function(done) { 347 | request(baseUrl) 348 | .post('/users') 349 | .send({ 350 | users: [{ name: 'Steve' }], 351 | }) 352 | .expect(403) 353 | .end(done); 354 | }); 355 | it('should be allowed to get user by id', function(done) { 356 | request(baseUrl).get('/users/' + userId).expect(200).end(done); 357 | }); 358 | it('should NOT be allowed to put user by id', function(done) { 359 | request(baseUrl) 360 | .put('/users/' + userId) 361 | .send({ 362 | users: [{ name: 'Joseph' }], 363 | }) 364 | .expect(403) 365 | .end(done); 366 | }); 367 | it.skip( 368 | 'should NOT be allowed to getChangeEventsStreaming for user', 369 | function() { 370 | throw new Error('Not implemented yet'); 371 | } 372 | ); 373 | 374 | it('should be allowed to get pets', function(done) { 375 | request(baseUrl).get('/pets').expect(200).end(done); 376 | }); 377 | it('should be allowed to post pet', function(done) { 378 | request(baseUrl) 379 | .post('/pets') 380 | .send({ 381 | pets: [{ name: 'Steve' }], 382 | }) 383 | .expect(201) 384 | .end(done); 385 | }); 386 | it('should be allowed to get pet by id', function(done) { 387 | request(baseUrl).get('/pets/' + petId).expect(200).end(done); 388 | }); 389 | it('should be allowed to put pet by id', function(done) { 390 | request(baseUrl) 391 | .put('/pets/' + petId) 392 | .send({ 393 | pets: [{ name: 'Joseph' }], 394 | }) 395 | .expect(200) 396 | .end(done); 397 | }); 398 | it.skip( 399 | 'should be allowed to getChangeEventsStreaming for pet', 400 | function() { 401 | throw new Error('Not implemented yet'); 402 | } 403 | ); 404 | }); 405 | }); 406 | -------------------------------------------------------------------------------- /test/seeder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let _ = require('lodash'); 3 | let request = require('supertest'); 4 | let Promise = require('bluebird'); 5 | 6 | let config = require('./config.js'); 7 | let fixtures = require('./fixtures'); 8 | 9 | /** 10 | * Configure seeding service. 11 | * 12 | * Sample usage: 13 | * 14 | * seed().seed('pets','people').then(function(ids){}); 15 | * seed(harvesterInstance,'http://localhost:8001').seed('pets','people').then(function(ids){}); 16 | * 17 | * @param harvesterInstance harvester instance that will be used to access database 18 | * @param baseUrl optional harvester's base url to post fixtures to 19 | * @returns {{dropCollectionsAndSeed: Function}} configured seeding service 20 | */ 21 | module.exports = function(harvesterInstance, baseUrl) { 22 | baseUrl = baseUrl || 'http://localhost:' + config.harvester.port; 23 | 24 | function post(key, value) { 25 | return new Promise(function(resolve, reject) { 26 | var body = {}; 27 | body[key] = value; 28 | request(baseUrl) 29 | .post('/' + key) 30 | .send(body) 31 | .expect('Content-Type', /json/) 32 | .expect(201) 33 | .end(function(error, response) { 34 | if (error) { 35 | reject(error); 36 | return; 37 | } 38 | var resources = JSON.parse(response.text)[key]; 39 | var ids = {}; 40 | ids[key] = []; 41 | _.forEach(resources, function(resource) { 42 | ids[key].push(resource.id); 43 | }); 44 | resolve(ids); 45 | }); 46 | }); 47 | } 48 | 49 | function drop(collectionName) { 50 | return new Promise(function(resolve) { 51 | var collection = harvesterInstance.adapter.db.collections[collectionName]; 52 | if (collection) { 53 | collection.drop(function() { 54 | resolve(); 55 | }); 56 | } else { 57 | resolve(); 58 | } 59 | }); 60 | } 61 | 62 | /** 63 | * Drop collections whose names are specified in vararg manner. 64 | * 65 | * @returns {*} array of collection names 66 | */ 67 | function dropCollections() { 68 | if (arguments.length === 0) { 69 | throw new Error('Collection names must be specified explicitly'); 70 | } 71 | var collectionNames = arguments.length === 0 72 | ? _.keys(fixtures()) 73 | : arguments; 74 | var promises = _.map(collectionNames, function(collectionName) { 75 | return drop(collectionName); 76 | }); 77 | return Promise.all(promises).then(function() { 78 | return collectionNames; 79 | }); 80 | } 81 | 82 | function dropCollectionsAndSeed() { 83 | return dropCollections 84 | .apply(this, arguments) 85 | .then(function(collectionNames) { 86 | var allFixtures = fixtures(); 87 | var promises = _.map(collectionNames, function(collectionName) { 88 | return post(collectionName, allFixtures[collectionName]); 89 | }); 90 | return Promise.all(promises); 91 | }) 92 | .then(function(result) { 93 | var response = {}; 94 | _.forEach(result, function(item) { 95 | _.extend(response, item); 96 | }); 97 | return response; 98 | }); 99 | } 100 | 101 | function seedCustomFixture(fixture) { 102 | var promises = _.map(fixture, function(items, collectionName) { 103 | return post(collectionName, items); 104 | }); 105 | return Promise.all(promises); 106 | } 107 | 108 | if (harvesterInstance == null) { 109 | throw new Error('Harvester instance is required param'); 110 | } 111 | 112 | return { 113 | dropCollections: dropCollections, 114 | dropCollectionsAndSeed: dropCollectionsAndSeed, 115 | seedCustomFixture: seedCustomFixture, 116 | }; 117 | }; 118 | -------------------------------------------------------------------------------- /test/send-error.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for the send-error module 3 | * 4 | * 5 | */ 6 | 'use strict'; 7 | 8 | // dependencies 9 | let should = require('should'); 10 | let JsonApiError = require('../lib/jsonapi-error'); 11 | 12 | // module under test 13 | let sendError = require('../lib/send-error'); 14 | 15 | describe('function sendError', function() { 16 | var req; 17 | var res; 18 | var error; 19 | 20 | // helper functions 21 | function mockFunc() { 22 | return res; 23 | } 24 | 25 | function standardJsonApiErrorValidation(body) { 26 | var json; 27 | 28 | should.exist(body); 29 | body.should.be.a.String; 30 | json = JSON.parse(body); 31 | should.exist(json.errors); 32 | json.errors.should.be.an.Array; 33 | json.errors.length.should.be.greaterThan(0); 34 | return json; 35 | } 36 | 37 | beforeEach(function() { 38 | // Shortened timeouts as there is a catch in `sendError` that swallows 39 | // errors thrown by `should` in the mocked `res` functions. Thus 40 | // causing these tests to fail by timeout, which is currently set to 41 | // 50 seconds. 42 | this.timeout(100); 43 | req = {}; 44 | res = { 45 | set: mockFunc, 46 | send: mockFunc, 47 | status: mockFunc, 48 | }; 49 | error = new JsonApiError({ status: '400' }); 50 | }); 51 | 52 | it("should accept a harvester#jsonapi-error object as it's third argument", function( 53 | done 54 | ) { 55 | res.send = function(body) { 56 | standardJsonApiErrorValidation(body); 57 | done(); 58 | }; 59 | sendError(req, res, error); 60 | }); 61 | it("should accept an array of harvester#jsonapi-error objects as it's third argument", function( 62 | done 63 | ) { 64 | error = [error]; 65 | res.send = function(body) { 66 | standardJsonApiErrorValidation(body); 67 | done(); 68 | }; 69 | sendError(req, res, error); 70 | }); 71 | it("should accept an array of 3 harvester#jsonapi-error objects as it's third argument", function( 72 | done 73 | ) { 74 | error = [error, error, error]; 75 | res.send = function(response) { 76 | var body = standardJsonApiErrorValidation(response); 77 | body.errors.length.should.equal(3); 78 | done(); 79 | }; 80 | sendError(req, res, error); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/singleRouteSSE.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let $http = require('http-as-promised'); 3 | let harvester = require('../lib/harvester'); 4 | let baseUrl = 'http://localhost:' + 8005; 5 | let chai = require('chai'); 6 | let expect = chai.expect; 7 | let ess = require('agco-event-source-stream'); 8 | let _ = require('lodash'); 9 | let config = require('./config.js'); 10 | let seeder = require('./seeder.js'); 11 | let Joi = require('joi'); 12 | let Promise = require('bluebird'); 13 | 14 | describe('EventSource implementation for resource changes', function() { 15 | var harvesterApp; 16 | describe('Server Sent Events', function() { 17 | this.timeout(20000); 18 | var lastEventId; 19 | 20 | before(function() { 21 | var options = { 22 | adapter: 'mongodb', 23 | connectionString: config.harvester.options.connectionString, 24 | db: 'test', 25 | inflect: true, 26 | oplogConnectionString: config.harvester.options.oplogConnectionString, 27 | }; 28 | 29 | /** 30 | * dvd resource should be declared after book, to test if it does not overwrite book sse config 31 | */ 32 | harvesterApp = harvester(options) 33 | .resource('book', { 34 | title: Joi.string(), 35 | author: Joi.string(), 36 | }) 37 | .resource('superHero', { 38 | timestamp: Joi.number(), 39 | }) 40 | .resource('dvd', { 41 | title: Joi.string(), 42 | }); 43 | 44 | harvesterApp.listen(8005); 45 | 46 | return seeder(harvesterApp, baseUrl).dropCollections( 47 | 'books', 48 | 'dvds', 49 | 'superHeros' 50 | ); 51 | }); 52 | 53 | describe('When I post to the newly created resource', function() { 54 | it('Then I should receive a change event with data but not the one before it', function( 55 | done 56 | ) { 57 | var eventSource = ess(baseUrl + '/books/changes/stream', { 58 | retry: false, 59 | }).on('data', function(res) { 60 | lastEventId = res.id; 61 | var data = JSON.parse(res.data); 62 | // ignore ticker data 63 | if (_.isNumber(data)) { 64 | // post data after we've hooked into change events and receive a ticker 65 | return seeder(harvesterApp, baseUrl).seedCustomFixture({ 66 | books: [ 67 | { 68 | title: 'test title 2', 69 | }, 70 | ], 71 | }); 72 | } 73 | expect(res.event.trim()).to.equal('books_i'); 74 | expect(_.omit(data, 'id')).to.deep.equal({ title: 'test title 2' }); 75 | done(); 76 | eventSource.destroy(); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('When I post resource with uppercased characters in name', function() { 82 | it('Then I should receive a change event', function(done) { 83 | var eventSource = ess(baseUrl + '/superHeros/changes/stream', { 84 | retry: false, 85 | }).on('data', function(data) { 86 | data = JSON.parse(data.data); 87 | expect(_.omit(data, 'id')).to.deep.equal({ timestamp: 123 }); 88 | done(); 89 | eventSource.destroy(); 90 | }); 91 | 92 | Promise.delay(100).then(function() { 93 | seeder(harvesterApp, baseUrl).seedCustomFixture({ 94 | superHeros: [ 95 | { 96 | timestamp: 123, 97 | }, 98 | ], 99 | }); 100 | }); 101 | }); 102 | }); 103 | 104 | describe('when I ask for events with ids greater than a certain id with filters enabled', function() { 105 | it('I should get only one event without setting a limit', function(done) { 106 | seeder(harvesterApp, baseUrl).seedCustomFixture({ 107 | books: [ 108 | { 109 | title: 'test title 3', 110 | }, 111 | { 112 | title: 'filtered', 113 | }, 114 | { 115 | title: 'filtered', 116 | author: 'Asimov', 117 | }, 118 | ], 119 | }); 120 | var eventSource = ess( 121 | baseUrl + 122 | '/books/changes/stream?title=filtered&author=Asimov&limit=100', 123 | { 124 | retry: false, 125 | headers: { 126 | 'Last-Event-ID': lastEventId, 127 | }, 128 | } 129 | ).on('data', function(response) { 130 | lastEventId = response.id; 131 | var data = JSON.parse(response.data); 132 | // ignore ticker data 133 | if (_.isNumber(data)) { 134 | return; 135 | } 136 | expect(_.omit(data, 'id')).to.deep.equal({ 137 | title: 'filtered', 138 | author: 'Asimov', 139 | }); 140 | done(); 141 | eventSource.destroy(); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('when I ask for events with ids greater than a certain id', function() { 147 | it('I should get only one event without setting a limit', function(done) { 148 | seeder(harvesterApp, baseUrl).seedCustomFixture({ 149 | books: [ 150 | { 151 | title: 'test title 3', 152 | }, 153 | ], 154 | }); 155 | var eventSource = ess(baseUrl + '/books/changes/stream', { 156 | retry: false, 157 | headers: { 158 | 'Last-Event-ID': lastEventId, 159 | }, 160 | }).on('data', function(response) { 161 | var data = JSON.parse(response.data); 162 | // ignore ticker data 163 | if (_.isNumber(data)) { 164 | return; 165 | } 166 | expect(_.omit(data, 'id')).to.deep.equal({ title: 'test title 3' }); 167 | done(); 168 | eventSource.destroy(); 169 | }); 170 | }); 171 | }); 172 | 173 | describe( 174 | 'Given a resource x with property y ' + '\nWhen the value of y changes', 175 | function() { 176 | it( 177 | 'Then an SSE is broadcast with event set to x_update, ID set to the oplog timestamp' + 178 | 'and data set to an instance of x that contains document id and new value for property y', 179 | function(done) { 180 | var payloads = [ 181 | { 182 | books: [ 183 | { 184 | title: 'test title 4', 185 | author: 'Asimov', 186 | }, 187 | ], 188 | }, 189 | { 190 | books: [ 191 | { 192 | title: 'test title 5', 193 | }, 194 | ], 195 | }, 196 | ]; 197 | $http 198 | .post(baseUrl + '/books', { json: payloads[0] }) 199 | .spread(function(res) { 200 | var counter = 0; 201 | var documentId = res.body.books[0].id; 202 | var expected = { 203 | _id: documentId, 204 | title: payloads[1].books[0].title, 205 | }; 206 | 207 | var eventSource = ess(baseUrl + '/books/changes/stream', { 208 | retry: false, 209 | }).on('data', function(response) { 210 | var data = JSON.parse(response.data); 211 | if (counter === 0) { 212 | $http.put(baseUrl + '/books/' + documentId, { 213 | json: payloads[1], 214 | }); 215 | } 216 | if (counter === 1) { 217 | expect(data).to.deep.equal(expected); 218 | } 219 | if (counter === 2) { 220 | done(); 221 | eventSource.destroy(); 222 | } 223 | counter++; 224 | }); 225 | }); 226 | } 227 | ); 228 | } 229 | ); 230 | 231 | 232 | describe( 233 | 'Given a resource x' + '\n When that resource is deleted', 234 | function() { 235 | it( 236 | 'Then an SSE is broadcast with event ID set to the oplog timestamp' + 237 | 'and data set to an object with the _id of the deleted document', 238 | function(done) { 239 | var payloads = [ 240 | { 241 | books: [ 242 | { 243 | title: 'test title 4', 244 | author: 'Asimov', 245 | }, 246 | ], 247 | } 248 | ]; 249 | $http 250 | .post(baseUrl + '/books', { json: payloads[0] }) 251 | .spread(function(res) { 252 | var counter = 0; 253 | var documentId = res.body.books[0].id; 254 | var expected = { 255 | _id: documentId 256 | }; 257 | 258 | var eventSource = ess(baseUrl + '/books/changes/stream', { 259 | retry: false, 260 | }).on('data', function(response) { 261 | var data = JSON.parse(response.data); 262 | if (counter === 0) { 263 | $http.del(baseUrl + '/books/' + documentId); 264 | } 265 | if (counter === 1) { 266 | expect(data).to.deep.equal(expected); 267 | } 268 | if (counter === 2) { 269 | done(); 270 | eventSource.destroy(); 271 | } 272 | counter++; 273 | }); 274 | }); 275 | } 276 | ); 277 | } 278 | ); 279 | describe('When I update collection document directly through mongodb adapter', function() { 280 | var documentId = ''; 281 | 282 | before(function(done) { 283 | seeder(harvesterApp, baseUrl) 284 | .seedCustomFixture({ 285 | books: [ 286 | { 287 | title: 'The Bourne Identity', 288 | author: 'Robert Ludlum', 289 | }, 290 | ], 291 | }) 292 | .then(function(result) { 293 | documentId = result[0].books[0]; 294 | done(); 295 | }); 296 | }); 297 | 298 | it('Then SSE should broadcast event with data containing properties that has been changed', function( 299 | done 300 | ) { 301 | var expected = { 302 | _id: documentId, 303 | title: 'The Bourne Supremacy', 304 | }; 305 | var counter = 0; 306 | var eventSource = ess(baseUrl + '/books/changes/stream', { 307 | retry: false, 308 | }).on('data', function(response) { 309 | var data = JSON.parse(response.data); 310 | if (counter === 0) { 311 | harvesterApp.adapter.db.collections.books.findOneAndUpdate( 312 | { _id: documentId }, 313 | { title: 'The Bourne Supremacy' } 314 | ); 315 | } 316 | if (counter === 1) { 317 | expect(data).to.deep.equal(expected); 318 | } 319 | if (counter === 2) { 320 | done(); 321 | eventSource.destroy(); 322 | } 323 | counter++; 324 | }); 325 | }); 326 | }); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /test/sorting.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let should = require('should'); 3 | let _ = require('lodash'); 4 | let request = require('supertest'); 5 | 6 | let seeder = require('./seeder.js'); 7 | 8 | describe('sorting', function() { 9 | var config; 10 | beforeEach(function() { 11 | config = this.config; 12 | return seeder(this.harvesterApp).dropCollectionsAndSeed('people', 'pets'); 13 | }); 14 | 15 | it('should be possible to sort by name', function(done) { 16 | request(config.baseUrl) 17 | .get('/people?sort=name') 18 | .expect(200) 19 | .end(function(err, res) { 20 | should.not.exist(err); 21 | var body = JSON.parse(res.text); 22 | _.pluck(body.people, 'name').should.eql([ 23 | 'Catbert', 24 | 'Dilbert', 25 | 'Wally', 26 | ]); 27 | done(); 28 | }); 29 | }); 30 | 31 | it('should be possible to sort by name desc', function(done) { 32 | request(config.baseUrl) 33 | .get('/people?sort=-name') 34 | .expect(200) 35 | .end(function(err, res) { 36 | should.not.exist(err); 37 | var body = JSON.parse(res.text); 38 | _.pluck(body.people, 'name').should.eql([ 39 | 'Wally', 40 | 'Dilbert', 41 | 'Catbert', 42 | ]); 43 | done(); 44 | }); 45 | }); 46 | 47 | it('should be possible to sort by appearances', function(done) { 48 | request(config.baseUrl) 49 | .get('/people?sort=appearances') 50 | .expect(200) 51 | .end(function(err, res) { 52 | should.not.exist(err); 53 | var body = JSON.parse(res.text); 54 | _.pluck(body.people, 'name').should.eql([ 55 | 'Catbert', 56 | 'Wally', 57 | 'Dilbert', 58 | ]); 59 | done(); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/validation.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let chai = require('chai'); 3 | let Joi = require('joi'); 4 | let chaiHttp = require('chai-http'); 5 | chai.use(chaiHttp); 6 | chai.request.addPromises(require('bluebird')); 7 | let expect = chai.expect; 8 | 9 | let should = require('should'); 10 | let request = require('supertest'); 11 | 12 | let validation = require('../lib/validation'); 13 | let seeder = require('./seeder.js'); 14 | 15 | describe('validation', function() { 16 | describe('validation body', function() { 17 | var schema = { 18 | body: Joi.object().keys({ 19 | stuff: Joi.array().items( 20 | Joi.object({ 21 | id: Joi.number().required().description('id'), 22 | links: Joi.object({ 23 | foo: Joi.string().guid(), 24 | bar: Joi.string().guid(), 25 | }), 26 | }) 27 | ), 28 | }), 29 | }; 30 | 31 | describe('when validating a valid resource', function() { 32 | it('should resolve', function() { 33 | var request = { 34 | body: { 35 | stuff: [ 36 | { 37 | id: 121212, 38 | links: { 39 | foo: 'bfebf5aa-e58b-410c-89e8-c3d8622bffdc', 40 | bar: '9ee7a0ec-8c06-4b0e-9a06-095b59fe815b', 41 | }, 42 | }, 43 | ], 44 | }, 45 | }; 46 | 47 | var details = validation(schema).validate(request); 48 | expect(details).to.be.empty; 49 | }); 50 | }); 51 | 52 | describe('when validating an invalid resource', function() { 53 | it('should resolve with errors', function() { 54 | var request = { 55 | body: { 56 | stuff: [ 57 | { 58 | bla: 'blabla', 59 | links: { 60 | baz: 'bfebf5aa-e58b-410c-89e8-c3d8622bffdc', 61 | bar: 'not a uuid', 62 | }, 63 | }, 64 | ], 65 | }, 66 | }; 67 | 68 | var details = validation(schema).validate(request); 69 | var bodyDetails = details.body; 70 | 71 | expect(bodyDetails).not.to.be.empty; 72 | 73 | expect(bodyDetails[0].path).to.equal('stuff.0.id'); 74 | expect(bodyDetails[0].message).to.equal('"id" is required'); 75 | 76 | expect(bodyDetails[1].path).to.equal('stuff.0.links.bar'); 77 | expect(bodyDetails[1].message).to.equal('"bar" must be a valid GUID'); 78 | 79 | expect(bodyDetails[2].path).to.equal('stuff.0.links.baz'); 80 | expect(bodyDetails[2].message).to.equal('"baz" is not allowed'); 81 | 82 | expect(bodyDetails[3].path).to.equal('stuff.0.bla'); 83 | expect(bodyDetails[3].message).to.equal('"bla" is not allowed'); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('validation query', function() { 89 | var schema = { 90 | query: { offset: Joi.number().required().description('offset') }, 91 | }; 92 | 93 | describe('when validating a valid resource', function() { 94 | it('should resolve', function() { 95 | var request = { 96 | query: { offset: 1 }, 97 | }; 98 | 99 | var details = validation(schema).validate(request); 100 | 101 | expect(details).to.be.empty; 102 | }); 103 | }); 104 | 105 | describe('when validating an invalid resource', function() { 106 | it('should resolve with errors', function() { 107 | var request = { 108 | query: { x: 'a' }, 109 | }; 110 | 111 | var details = validation(schema).validate(request); 112 | var queryDetails = details.query; 113 | 114 | expect(queryDetails[0].path).to.equal('offset'); 115 | expect(queryDetails[0].message).to.equal('"offset" is required'); 116 | }); 117 | }); 118 | }); 119 | 120 | describe('validation params', function() { 121 | var schema = { params: { id: Joi.number().required().description('id') } }; 122 | 123 | describe('when validating a valid resource', function() { 124 | it('should resolve', function() { 125 | var request = { 126 | params: { id: 121212 }, 127 | }; 128 | 129 | var details = validation(schema).validate(request); 130 | 131 | expect(details).to.be.empty; 132 | }); 133 | }); 134 | 135 | describe('when validating an invalid resource', function() { 136 | it('should resolve with errors', function() { 137 | var request = { params: {} }; 138 | 139 | var details = validation(schema).validate(request); 140 | var paramsDetails = details.params; 141 | 142 | expect(paramsDetails[0].path).to.equal('id'); 143 | expect(paramsDetails[0].message).to.equal('"id" is required'); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('validation headers', function() { 149 | var schema = { 150 | headers: { 151 | Authorization: Joi.string() 152 | .required() 153 | .description('Authorization header'), 154 | }, 155 | }; 156 | 157 | describe('when validating a valid resource', function() { 158 | it('should resolve', function() { 159 | var request = { 160 | headers: { Authorization: 'Bearer abcdefghikjlm1234567' }, 161 | }; 162 | 163 | var details = validation(schema).validate(request); 164 | expect(details).to.be.empty; 165 | }); 166 | }); 167 | 168 | describe('when validating an invalid resource', function() { 169 | it('should resolve with errors', function() { 170 | var request = { headers: {} }; 171 | 172 | var details = validation(schema).validate(request); 173 | var headerDetails = details.headers; 174 | 175 | expect(headerDetails[0].path).to.equal('Authorization'); 176 | expect(headerDetails[0].message).to.equal( 177 | '"Authorization" is required' 178 | ); 179 | }); 180 | }); 181 | }); 182 | 183 | // todo refactor, we should reduce code duplication a bit on these tests 184 | describe('validation api calls', function() { 185 | var config, ids; 186 | beforeEach(function() { 187 | config = this.config; 188 | return seeder(this.harvesterApp) 189 | .dropCollectionsAndSeed('people', 'pets') 190 | .then(function(_ids) { 191 | ids = _ids; 192 | }); 193 | }); 194 | 195 | describe('when a resource is POSTed with a malformed payload which has a required attribute "appearances" missing', function() { 196 | it('should resolve with a 400 and a validationErrorDetails section stating "appearances" is required', function( 197 | done 198 | ) { 199 | var pet = { 200 | name: 'Spot', 201 | }, 202 | pets = { pets: [] }; 203 | pets.pets.push(pet); 204 | 205 | request(config.baseUrl) 206 | .post('/pets') 207 | .send(pets) 208 | .expect('Content-Type', /json/) 209 | .expect(400) 210 | .expect(function(res) { 211 | var error = JSON.parse(res.text).errors[0]; 212 | var bodyDetails = error.meta.validationErrorDetails.body; 213 | 214 | expect(error.detail).to.equal( 215 | 'validation failed on incoming request' 216 | ); 217 | 218 | expect(bodyDetails[0].path).to.equal('pets.0.appearances'); 219 | expect(bodyDetails[0].message).to.equal( 220 | '"appearances" is required' 221 | ); 222 | }) 223 | .end(function(err) { 224 | if (err) { 225 | return done(err); 226 | } 227 | done(); 228 | }); 229 | }); 230 | }); 231 | 232 | describe('when a resource is PUT with a malformed payload which has an unknown attribute "foo" defined', function() { 233 | it('should resolve with a 400 and a validationErrorDetails section stating "foo" is not allowed', function( 234 | done 235 | ) { 236 | var pet = { 237 | name: 'Spot', 238 | foo: true, 239 | }, 240 | pets = { pets: [] }; 241 | pets.pets.push(pet); 242 | 243 | request(config.baseUrl) 244 | .put('/pets/' + ids.pets[0]) 245 | .send(pets) 246 | .expect('Content-Type', /json/) 247 | .expect(400) 248 | .expect(function(res) { 249 | var error = JSON.parse(res.text).errors[0]; 250 | var bodyDetails = error.meta.validationErrorDetails.body; 251 | 252 | expect(error.detail).to.equal( 253 | 'validation failed on incoming request' 254 | ); 255 | expect(bodyDetails[0].path).to.equal('pets.0.foo'); 256 | expect(bodyDetails[0].message).to.equal('"foo" is not allowed'); 257 | }) 258 | .end(function(err) { 259 | if (err) { 260 | return done(err); 261 | } 262 | done(); 263 | }); 264 | }); 265 | }); 266 | 267 | describe('when a resource is PUT with a malformed payload which has multiple primary resource collection entries', function() { 268 | it('should resolve with a 400 and a validationErrorDetails section stating "pets" must contain 1 items', function( 269 | done 270 | ) { 271 | var pets = { pets: [] }; 272 | pets.pets.push({ 273 | name: 'Spot', 274 | }); 275 | pets.pets.push({ 276 | name: 'Blacky', 277 | }); 278 | 279 | request(config.baseUrl) 280 | .put('/pets/' + ids.pets[0]) 281 | .send(pets) 282 | .expect('Content-Type', /json/) 283 | .expect(400) 284 | .expect(function(res) { 285 | var error = JSON.parse(res.text).errors[0]; 286 | var bodyDetails = error.meta.validationErrorDetails.body; 287 | 288 | expect(error.detail).to.equal( 289 | 'validation failed on incoming request' 290 | ); 291 | expect(bodyDetails[0].path).to.equal('pets'); 292 | expect(bodyDetails[0].message).to.equal( 293 | '"pets" must contain 1 items' 294 | ); 295 | }) 296 | .end(function(err) { 297 | if (err) { 298 | return done(err); 299 | } 300 | done(); 301 | }); 302 | }); 303 | }); 304 | 305 | describe('when a resource is PUT with a malformed payload which has no primary resource collection entries', function() { 306 | it('should resolve with a 400 and a validationErrorDetails section stating "pets" must contain 1 items', function( 307 | done 308 | ) { 309 | var pets = { pets: [] }; 310 | 311 | request(config.baseUrl) 312 | .put('/pets/' + ids.pets[0]) 313 | .send(pets) 314 | .expect('Content-Type', /json/) 315 | .expect(400) 316 | .expect(function(res) { 317 | var error = JSON.parse(res.text).errors[0]; 318 | var bodyDetails = error.meta.validationErrorDetails.body; 319 | 320 | expect(error.detail).to.equal( 321 | 'validation failed on incoming request' 322 | ); 323 | expect(bodyDetails[0].path).to.equal('pets'); 324 | expect(bodyDetails[0].message).to.equal( 325 | '"pets" must contain 1 items' 326 | ); 327 | }) 328 | .end(function(err) { 329 | if (err) { 330 | return done(err); 331 | } 332 | done(); 333 | }); 334 | }); 335 | }); 336 | 337 | describe('when resource has Joi.object property', function() { 338 | it('should allow persisting valid object', function(done) { 339 | var object = { 340 | foo: { 341 | bar: 'Jack', 342 | any: 'ali boom boom', 343 | }, 344 | }; 345 | request(config.baseUrl) 346 | .post('/objects') 347 | .send({ objects: [object] }) 348 | .expect('Content-Type', /json/) 349 | .expect(201) 350 | .end(function(err) { 351 | should.not.exist(err); 352 | done(); 353 | }); 354 | }); 355 | it('should allow persisting another valid object', function(done) { 356 | var object = { 357 | foo: { 358 | bar: 'Jack', 359 | tab: { 360 | bats: [1, 2, 3], 361 | }, 362 | any: { 363 | ali: 'boom boom', 364 | }, 365 | }, 366 | }; 367 | request(config.baseUrl) 368 | .post('/objects') 369 | .send({ objects: [object] }) 370 | .expect('Content-Type', /json/) 371 | .expect(201) 372 | .end(function(err) { 373 | should.not.exist(err); 374 | done(); 375 | }); 376 | }); 377 | it('should NOT allow persisting object with missing required object property', function( 378 | done 379 | ) { 380 | var object = {}; 381 | request(config.baseUrl) 382 | .post('/objects') 383 | .send({ objects: [object] }) 384 | .expect('Content-Type', /json/) 385 | .expect(400) 386 | .expect(function(res) { 387 | var error = JSON.parse(res.text).errors[0]; 388 | var bodyDetails = error.meta.validationErrorDetails.body; 389 | expect(error.detail).to.equal( 390 | 'validation failed on incoming request' 391 | ); 392 | expect(bodyDetails[0].path).to.equal('objects.0.foo'); 393 | expect(bodyDetails[0].message).to.equal('"foo" is required'); 394 | }) 395 | .end(function(err) { 396 | should.not.exist(err); 397 | done(); 398 | }); 399 | }); 400 | it('should NOT allow persisting object with additional inner property not defined in schema', function( 401 | done 402 | ) { 403 | var object = { 404 | foo: { 405 | rab: 'Jack', 406 | }, 407 | }; 408 | request(config.baseUrl) 409 | .post('/objects') 410 | .send({ objects: [object] }) 411 | .expect('Content-Type', /json/) 412 | .expect(400) 413 | .expect(function(res) { 414 | var error = JSON.parse(res.text).errors[0]; 415 | var bodyDetails = error.meta.validationErrorDetails.body; 416 | expect(error.detail).to.equal( 417 | 'validation failed on incoming request' 418 | ); 419 | expect(bodyDetails[0].path).to.equal('objects.0.foo.rab'); 420 | expect(bodyDetails[0].message).to.equal('"rab" is not allowed'); 421 | }) 422 | .end(function(err) { 423 | should.not.exist(err); 424 | done(); 425 | }); 426 | }); 427 | }); 428 | }); 429 | }); 430 | --------------------------------------------------------------------------------