diff --git a/README.md b/README.md index 6e29adb..778f6ce 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,19 @@ It is built on top of [gcloud-node](https://github.com/GoogleCloudPlatform/gclou - [Installation](#installation) - [What do I get from it](#what-do-i-get-from-it) - [Getting started](#getting-started) - - [Initiate](#initiate) -- [Create a REST API for an Entity](#create-a-rest-api-for-an-entity) - - [Basic](#basic) - - [Additional settings](#additional-settings) + - [Initiate library](#initiate-library) +- [Create an API for an Entity](#create-an-api-for-an-entity) + - [settings](#settings) - [path](#path) - [ancestors](#ancestors) - [op](#op) + - [op settings](#op-settings) ## Motivation -While I was working on the [gstore-node](https://github.com/sebelga/gstore-node) library I was building a REST API -for a mobile project. I found myself copying a lot of the same code over and over to create all the routes and controllers needed to manage my Datastore entities. So I decided to create this small utility to help me generate all the REST routes for CRUD operations on the Google Datastore entities. +While I was coding the [gstore](https://github.com/sebelga/gstore-node) library I was working on an REST API +for a mobile project. I found myself copying a lot of the same code to create all the routes and controllers needed to manage my Datastore entities. So I decided to create this small utility to help me create all the REST routes for CRUD operations over the Google Datastore entities. ## Installation @@ -40,7 +40,7 @@ for a mobile project. I found myself copying a lot of the same code over and ove **Without** gstoreApi -``` +```js // blogPost.routes.js var BlogPostController = require('../controllers/blogPost.controller'); @@ -69,7 +69,7 @@ module.exports = BlogPostRoutes; ``` -``` +```js // blogPost.controller.js // BlogPost is a Datastools Model @@ -146,7 +146,7 @@ module.exports = { **With** gstoreApi -``` +```js // server.js var gstoreApi = require('datastore-api'); @@ -157,16 +157,16 @@ gstoreApi.init({ ``` -The next file is all you need to have a REST API for a "BlogPost" Datastore Entity +The next file is all you need to have a full CRUD REST API of a [gstore-node BlogPost Model](https://github.com/sebelga/gstore-node#model) -``` +```js // lib var gstoreApi = require('gstore-api'); // Model (gstore-node) var BlogPost = require('../models/blogPost'); -module.exports = function() { +module.exports = function() { // --> REST API for Model new datastoreApi(BlogPost); } @@ -176,28 +176,33 @@ module.exports = function() { ## Getting started -### Initiate +### Initiate library Before using gstoreApi you need to initiate the library with `gstoreApi.init({...settings})` The settings is an object with the following properties: - router -- simplifyResult // (optional) default: true -- contexts // (optional) +- host (optional) +- simplifyResult(optional) default: true +- contexts (optional) -**router** +**router** The Express Router instance -**simplifyResult** +**host** +Specify the host of your API. It is needed to create the \ Header in the Model.list() response that contains the next pageCursor. If you don't specify it will be auto-generated with the info in the request +`req.protocol + '://' + req.get('host') + req.originalUrl` + +**simplifyResult** Define globally if the response format is simplified or not. See explanation in [gstore-node docs](https://github.com/sebelga/gstore-node#queries) -**context** -Contexts is an objects with 2 properties: "**public**" and "**private**" that specify a sufix for the routes to be generated. +**context** +Contexts is an objects with 2 properties: "**public**" and "**private**" that specify a suffix for the routes to be generated. gstoreApi considers that "GET" calls (that don't mutate the resource) are *public* and all others (POST, PUT, PATCH, DELETE) are *private*. -Its default value is an object that does not add any sufix to any route. +Its default value is an object that does not add any suffix to any route. -``` +```js { public : '', private : '' @@ -206,7 +211,7 @@ Its default value is an object that does not add any sufix to any route. But for example if you initiate gstoreApi like this -``` +```js gstoreApi.init({ router : router, contexts : { @@ -218,55 +223,42 @@ gstoreApi.init({ And you defined an Auth middelware -``` +```js router.use('/private/', yourAuthMiddelware); ``` Then all the POST, PUT, PATCH and DELETE routes will automatically be routed through your Auth middelware. -## Create a REST API for an Entity +## Create an API for an Entity -### Basic -To its simplest form, to create an API for a Model you just need to create a new instance of the gstoreApi and pass the Model. +To its simplest form, to create an API for a Model you just need to create a new instance of the gstoreApi and pass the gstore-node Model. -``` +```js var gstoreApi = require('gstore-api'); var Model = require('../models/my-model'); new gstoreApi(Model); ``` -### Additional settings +### settings If you need some fine-tuning, the gstoreApi constructor has a second parameter where you can configure the following settings -``` -// NOTE: All the settings below are OPTIONAL. You only need to set the one you need to tweak. +```js +// NOTE: All the settings below are OPTIONAL. Just define what you need to tweak. { - path: '/end-point', // if not specified will be automatically generated (see below) - ancestors : 'Dad', // can also ben an array ['GranDad', 'Dad'] + path: '/end-point', // if not specified will be auto-generated (see below) + ancestors : 'Dad', // can also ben an ['GranDad', 'Dad'] op : { - list : { - fn : someController.someMethod, - middelware : someMiddelware, - exec : true, (default true) - options : { - simplifyResult : true, (default to settings in init()) - readAll : false, (default false) - }, - path : { - prefix : 'additional-prefix' // can also be an . - sufix : 'additional-sufix' // not sure why I added this feature, but it's there :) - } - }, - get : {...} // same as above + list : {... see op setting below}, + get : {...} create : {...}, - udpatePatch : {...}, // PATCH verb - updateReplace : {...}, // PUT verb + udpatePatch : {...}, // PATCH :id + updateReplace : {...}, // PUT :id delete : {...}, - deleteAll : {...} // exec defaults to false (for security) + deleteAll : {...} } } @@ -274,26 +266,26 @@ If you need some fine-tuning, the gstoreApi constructor has a second parameter w ``` #### path -If you don't pass the path for the resource it will be **auto-generated** with the following rules: +If not set the path to the resource is **auto-generated** with the following rules: - lowercase - dash for camelCase -- pluralize the entity Kind +- pluralize entity Kind -``` +```js Example: entity Kind path ---------------------------- -'BlogPost' --> '/blog-posts' +'BlogPost' --> '/blog-posts' 'Query' --> '/queries' ``` #### ancestors -You can set here one or several ancestors (Entity Kinds) for the current Model. The path that is generated follows the same rules as the path setting above. +You can pass here one or several ancestors (entity Kinds) for the Model. The path create follows the same rules as mentioned above. -``` +```js // gstore-node Model var Comment = require('./models/comment.model'); @@ -328,24 +320,47 @@ new gstoreApi(Comment, { Operations can be any of -- list (GET all entities) +- list (GET all entities) --> call the list() query shortcut on Model. [Documentation here](https://github.com/sebelga/gstore-node#list). - get (GET one entity) - create (POST new entity) -- updatePatch (PATCH update entity) --> only update the properties sent in body +- updatePatch (PATCH update entity) --> only update properties sent - updateReplace (PUT to update entity) --> replace all data for entity - delete (DELETE one entity) - deleteAll (DELETE all entities) -They all have the **same configuration settings** with the following properties +**Link Header** +The **list** operation adds a [Link Header](https://tools.ietf.org/html/rfc5988#page-6) (rel="next") with the link to the next page to fetch if there are more result. -**fn** -Controller function to call. If you don't pass it it will default to get and return the entity from Google Datastore. +##### op settings -**middelware** -You can specify a custom middelware for any operation. You might want, for example, to add a middleware to upload files. +Each operation has the **same configuration settings** with the following properties +```js +{ + fn : someController.someMethod, + middelware : someMiddelware, + exec : true, (default true) + options : { + simplifyResult : true, (default to settings passed in init()) + readAll : false, (default false) + }, + path : { + prefix : 'additional-prefix' // can also be an . + suffix : 'additional-suffix' // not sure why I added this feature, but it's there :) + } +} ``` + + +**fn** +Custom Controller method. Like any Express Router method, it has the **request** and **response** parameters. `function controllerMethod(req, res) {...}` + + +**middelware** +You can specify a custom middelware for any operation. You might want for example to specify a middleware to upload a file. + +```js // Upload file with multer package var multer = require('multer'); var storage = multer.memoryStorage(); @@ -362,25 +377,38 @@ new gstoreApi(Image, { op : { create : { middelware : upload.single('file'), - fn : imageController.create // Add a custom logic just for this POST + fn : imageController.create // Add a custom Controller for the POST } } }); -// The following will have the middelware added and call the custom controller method +// The following will have the middelware added and call the custom Controller method POST /images ``` -**exec** -You define with this property to execute or not this operation on the entity Model. Defaults to **true** except for "*deleteAll*" operation that you must manually set to true for security reason. +**exec** +This property defines if the route for the operation is created (and therefore executed) or not. Defaults to **true** except for "*deleteAll*" that you must manually set to true for security reason. + +**options** + +- **simplifyResult**: for list operation it will pass this setting to the Model.list() action. And for get, create, and update(s) operation it will call entity.plain() +- **readAll**: (default: false) in case you have defined some properties in your Schema with read:false ([see the doc](https://github.com/sebelga/gstore-node#read)), they won't show up in the response. If you want them in the response set this property to true. + +Extra options: +**Only** for the "list" operation, there are some extra options that you can set to override any of the shortcut query "list" settings. [See the docs](https://github.com/sebelga/gstore-node#list) + +- **limit** +- **order** +- **select** +- **ancestors** (except if already defined in global init()) +- **filters** + -**options** +**path** +You can add here some custom prefix or suffix to the path. -- **simplifyResult**: for the list() operation it will forward this setting to the Model.list() action. For get, create, and update(s) operations it will call entity.plain() on the response. -- **readAll**: (default: false) in case you have set the **read setting** on some of your Schema's properties to false ([see the doc](https://github.com/sebelga/gstore-node#read)), these won't show up in the response data unless you set "readAll" to true. +If you pass an **\** of prefix like this `['', '/private', '/some-other-route']`, this will create 3 endPoints to access your entity and so let you define 3 different middelwares if needed. You will then have to define a custom Controller method (fn) to deal with these differents scenarios and customize the data saved or returned. For example: outputing more data if the user is authenticated. -**path** -You can add here some custom prefix or suffix to the path. -Important: This will override the settings from the global "contexts". +Important: This "path" setting will override the global "contexts" settings. diff --git a/lib/index.js b/lib/index.js index d4d3433..e9da940 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,7 +5,7 @@ const extend = require('extend'); const errorsHandler = require('./errorsHandler'); const arrify = require('arrify'); const S = require('string'); -const pluralize = require('pluralize') +const pluralize = require('pluralize'); const OPERATIONS = ['list', 'get', 'create', 'updatePatch', 'updateReplace', 'delete', 'deleteAll']; @@ -97,27 +97,39 @@ class DatastoreApi { } list(req, res) { + let _this = this; let listConfig = this.settings.op && this.settings.op.hasOwnProperty('list') ? this.settings.op.list : {}; if (listConfig.fn) { listConfig.fn(req, res); } else { let options = listConfig.options || {}; - let simplify = options.hasOwnProperty('simplifyResult') ? options.simplifyResult : this.settings.simplifyResult; let ancestors = ancestorsFromParams(req.params, this.settings.ancestors); - let settings = { - simplifyResult:simplify - }; + + let settings = extend(true, {}, options); + if (!settings.hasOwnProperty('simplifyResult')) { + settings.simplifyResult = this.settings.simplifyResult; + } if (ancestors) { settings.ancestors = ancestors; } - this.Model.list(settings, (err, entities) => { + if (req.query.pageCursor) { + settings.start = req.query.pageCursor; + } + + this.Model.list(settings, (err, result) => { if (err) { return errorsHandler.rpcError(err, res); } - res.json(entities); + if (result.nextPageCursor) { + var linkNextPage = _this.settings.host || req.protocol + '://' + req.get('host'); + req.originalUrl = req.originalUrl.replace(/\?pageCursor=[^&]+/, ''); + linkNextPage += req.originalUrl + '?pageCursor=' + result.nextPageCursor; + res.set('Link', '<' + linkNextPage + '>; rel="next"'); + } + res.json(result.entities); }); } } @@ -299,7 +311,7 @@ class DatastoreApi { fn: myController.method, path : { prefix: 'some-prefix', - sufix: 'some-sufix' + suffix: 'some-suffix' } } */ @@ -325,7 +337,7 @@ function createRoutes(self) { prefix = config && config.path && config.path.prefix ? config.path.prefix : prefix; let prefixs = arrify(prefix); - let sufix = config && config.path && config.path.sufix ? config.path.sufix : ''; + let suffix = config && config.path && config.path.suffix ? config.path.suffix : ''; let middelware = config.middelware ? [config.middelware] : null; let ancestors = settings.hasOwnProperty('ancestors') ? arrify(settings.ancestors) : undefined; @@ -336,7 +348,7 @@ function createRoutes(self) { prefix += '/' + pathFromEntityKind(ancestor) + '/:anc' + index + 'ID'; }); } - let path = pathWithId.indexOf(op) < 0 ? prefix + settings.path + sufix : prefix + settings.path + '/:id' + sufix; + let path = pathWithId.indexOf(op) < 0 ? prefix + settings.path + suffix : prefix + settings.path + '/:id' + suffix; paths.push(path); }); diff --git a/package.json b/package.json index 2a6a19a..933db39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstore-api", - "version": "0.1.0", + "version": "0.2.0", "description": "gstore-api is a NodeJS Express route builder that help designing RESTful APIs to manage Google Datastore entities.", "main": "index.js", "scripts": { diff --git a/test/index-test.js b/test/index-test.js index 214c7fe..fbb77b6 100644 --- a/test/index-test.js +++ b/test/index-test.js @@ -73,11 +73,18 @@ describe('Datastore API', function() { Model = gstore.model('BlogPost', schema); - req = {params:{id:123, anc0ID:'ancestor1', anc1ID:'ancestor2'}, body:{title:'Blog Title'}}; + req = { + params:{id:123, anc0ID:'ancestor1', anc1ID:'ancestor2'}, + query : {}, + body:{title:'Blog Title'}, + get : () => 'http://localhost', + originalUrl : '' + }; res = { status:() => { return {json:() => {}} }, + set : () => {}, json:() => {} }; @@ -112,6 +119,7 @@ describe('Datastore API', function() { it('should merge settings passed', () => { var settings = { + host: '', contexts : { public:'/public', private : '/private' @@ -187,9 +195,9 @@ describe('Datastore API', function() { args.push(arguments[i]); } cb = args.pop(); - settings = args.length > 0 ? args[0] : undefined; - - return cb(null, entities); + return cb(null, { + entities : entities + }); }); }); @@ -198,7 +206,7 @@ describe('Datastore API', function() { }); it('should read from general settings', () => { - var dsApi = new gstoreApi(Model, {path:'/users', simplifyResult:true}, router); + var dsApi = new gstoreApi(Model, {path:'/users', simplifyResult:true}); dsApi.list(req, res); @@ -206,12 +214,46 @@ describe('Datastore API', function() { expect(res.json.getCall(0).args[0]).equal(entities); }); + it('should set Link Header if nextPageCursor', () => { + Model.list.restore(); + sinon.stub(Model, 'list', function(settings, cb) { + return cb(null, { + nextPageCursor : 'abc123' + }); + }); + sinon.spy(res, 'set'); + var dsApi = new gstoreApi(Model); + + dsApi.list(req, res); + + expect(res.set.called).be.true; + expect(res.set.getCall(0).args[0]).equal('Link'); + + res.set.restore(); + }); + + it('should add start setting if pageCursor in request query', () => { + req.query.pageCursor = 'abcd1234'; + var dsApi = new gstoreApi(Model); + + dsApi.list(req, res); + + expect(Model.list.getCall(0).args[0]).deep.equal({simplifyResult:false, start:'abcd1234'}); + }); + it('should read options', () => { var settings = { path:'/users', op : { list : { - options : {simplifyResult:true} + options : { + simplifyResult:true, + limit : 13, + order : {property: 'title', descending:true}, + select : 'name', + ancestors : ['MyDad'], + filters : ['paid', true] + } } } }; @@ -219,13 +261,20 @@ describe('Datastore API', function() { dsApi.list(req, res); - expect(Model.list.getCall(0).args[0]).deep.equal({simplifyResult:true}); + expect(Model.list.getCall(0).args[0]).deep.equal(settings.op.list.options); }); it('should add ancestors to query', () => { var settings1 = { path:'/users', - ancestors : 'Dad' + ancestors : 'Dad', + op : { + list : { + options : { + ancestors : ['MyDad'] + } + } + } }; var dsApi1 = new gstoreApi(Model, settings1, router); @@ -901,12 +950,12 @@ describe('Datastore API', function() { expect(router.route.getCall(3).args[0]).equal('/myprefix2/users/:id'); }); - it('should add sufix to path', () => { + it('should add suffix to path', () => { var dsApi = new gstoreApi(Model, { path:'/users', op : { - list: {path:{sufix:'/mysufix'}}, - get: {path:{sufix:'/mysufix'}} + list: {path:{suffix:'/mysufix'}}, + get: {path:{suffix:'/mysufix'}} } }); expect(router.route.getCall(0).args[0]).equal('/public/users/mysufix');