├── index.js ├── .travis.yml ├── .jshintrc ├── lib ├── util │ ├── index.js │ ├── validate-options.js │ ├── apply-links.js │ ├── handle-optional-arguments.js │ ├── deserialize-relationship.js │ ├── deserialize-resource.js │ └── serialize-resource.js ├── errors.js └── index.js ├── package.json ├── Gruntfile.js ├── LICENSE.md ├── test ├── serialize-error.test.js ├── process-resource.test.js ├── synchronous.test.js ├── is-an-id.test.js ├── complex.test.js ├── deserialize.test.js └── basic.test.js ├── .gitignore └── README.md /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib'); 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.0" 4 | - "4.1" 5 | - "4.0" 6 | - "stable" 7 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "expr": true, 4 | "node": true, 5 | "predef": [ 6 | "after", 7 | "afterEach", 8 | "before", 9 | "beforeEach", 10 | "context", 11 | "describe", 12 | "it" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /lib/util/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | applyLinks: require('./apply-links'), 5 | deserializeResource: require('./deserialize-resource'), 6 | handleOptionalArguments: require('./handle-optional-arguments'), 7 | serializeResource: require('./serialize-resource'), 8 | validateOptions: require('./validate-options') 9 | }; 10 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var errors = { 4 | '1': { 5 | status: 500, 6 | title: 'An undefined error occured' 7 | }, 8 | '1001': { 9 | status: 500, 10 | title: 'Invalid `processDocument` hook' 11 | }, 12 | '1002': { 13 | status: 500, 14 | title: 'Invalid `type` specified' 15 | }, 16 | '1003': { 17 | status: 500, 18 | title: 'Invalid `options` specified' 19 | }, 20 | '2001': { 21 | status: 500, 22 | title: 'Invalid data passed to serializer' 23 | } 24 | }; 25 | 26 | module.exports = { 27 | generateError(code, detail, meta) { 28 | let err = errors[code] || errors['1']; 29 | err.detail = detail || 'No detail provided'; 30 | err.meta = meta; 31 | return err; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-api-ify", 3 | "version": "1.1.1", 4 | "description": "json to json-api compliant payload", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test/**/*.test.js --recursive" 8 | }, 9 | "keywords": [ 10 | "json-api", 11 | "json", 12 | "jsonapi", 13 | "serializer", 14 | "deserializer" 15 | ], 16 | "author": "chris ludden", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/kutlerskaggs/json-api-ify" 21 | }, 22 | "dependencies": { 23 | "async": "^1.5.2", 24 | "joi": "^8.0.1", 25 | "lodash": "^4.5.0" 26 | }, 27 | "devDependencies": { 28 | "bson-objectid": "^1.1.4", 29 | "chai": "^3.5.0", 30 | "grunt": "^0.4.5", 31 | "grunt-mocha-istanbul": "^3.0.1", 32 | "istanbul": "^0.4.2", 33 | "mocha": "^2.4.5", 34 | "mongodb": "^2.1.7", 35 | "mongoose": "^4.4.7", 36 | "query-string": "^3.0.0", 37 | "sinon": "^1.17.3", 38 | "sinon-chai": "^2.8.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt){ 4 | grunt.initConfig({ 5 | mocha_istanbul: { 6 | coverage: { 7 | src: 'test', // the folder, not the files 8 | options: { 9 | coverageFolder: 'coverage', 10 | mask: '*.test.js' 11 | } 12 | }, 13 | partial: { 14 | src: ['test/*.test.js'] 15 | } 16 | } 17 | }); 18 | 19 | grunt.loadNpmTasks('grunt-mocha-istanbul'); 20 | 21 | grunt.registerTask('coverage', ['mocha_istanbul:coverage']); 22 | grunt.registerTask('partial-coverage', function() { 23 | var tests = Array.prototype.slice.call(arguments, 0).map(function(test) { 24 | return 'test/' + test + '.test.js'; 25 | }); 26 | if (tests.length > 0) { 27 | grunt.config('mocha_istanbul.partial.src', ['test/tests/bootstrap.test.js'].concat(tests)); 28 | } 29 | grunt.task.run('mocha_istanbul:partial'); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Chris Ludden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/serialize-error.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'), 4 | Serializer = require('../index'); 5 | 6 | let expect = chai.expect; 7 | 8 | describe('serializeError()', function() { 9 | let serializer = new Serializer(); 10 | 11 | let errors = [ 12 | 500, 13 | 'An error occurred', 14 | {random: 'This is an error'}, 15 | {error: 'Something happened here'}, 16 | new Error('Something unexpected'), 17 | { 18 | id: 'abdoihewoihcwwe', 19 | status: 403, 20 | title: 'ECONNECT', 21 | detail: 'sadlkjasldfkjalskd' 22 | } 23 | ]; 24 | 25 | errors.forEach(function(err) { 26 | it('should produce a valid error document', function() { 27 | let payload = serializer.serializeError(err); 28 | expect(payload).to.be.an('object') 29 | .that.contains.all.keys('errors') 30 | .and.contains.any.keys('meta') 31 | .and.property('errors').that.is.an('array'); 32 | payload.errors.forEach(function(e) { 33 | expect(serializer._validateError(e)).to.equal(true); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /lib/util/validate-options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var errors = require('../errors'), 4 | joi = require('joi'); 5 | 6 | module.exports = function validateOptions(options, cb) { 7 | let optionsSchema = joi.object({ 8 | blacklist: joi.array().items(joi.string()).single().default([]), 9 | id: joi.alternatives().try( 10 | joi.string(), 11 | joi.func() 12 | ), 13 | processCollection: joi.func(), 14 | processResource: joi.func(), 15 | relationships: joi.object().pattern(/.+/, joi.object({ 16 | type: joi.string().required(), 17 | include: joi.boolean().default(true) 18 | })).default({}).description('the relationships definition for this resource type'), 19 | topLevelLinks: joi.object().default({}), 20 | topLevelMeta: joi.object().default({}), 21 | whitelist: joi.array().items(joi.string()).single().default([]) 22 | }).required(); 23 | 24 | joi.validate(options, optionsSchema, {allowUnknown: true, convert: true}, function(err, validated) { 25 | if (err) { 26 | return cb(errors.generateError('1003', err.message, {error: err})); 27 | } 28 | cb(null, validated); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/util/apply-links.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | _ = require('lodash'); 5 | 6 | module.exports = function(definition, links, resource, options, cb) { 7 | let args = []; 8 | if (arguments.length === 5) { 9 | args.push(resource, options); 10 | } else { 11 | cb = options; 12 | options = resource; 13 | args.push(options); 14 | } 15 | 16 | async.each(_.toPairs(definition), function(pair, fn) { 17 | let link = pair[1]; 18 | let callback = function(err, calculated) { 19 | if (err) { 20 | return fn(err); 21 | } 22 | if (typeof calculated !== 'undefined') { 23 | _.set(links, pair[0], calculated); 24 | } 25 | fn(); 26 | }; 27 | 28 | if (_.isFunction(link)) { 29 | let tempArgs = args.slice(); 30 | if (link.length > tempArgs.length) { 31 | tempArgs.push(callback); 32 | return link.apply(null, tempArgs); 33 | } else { 34 | link = link.apply(null, tempArgs); 35 | if (typeof link !== 'undefined') { 36 | _.set(links, pair[0], link); 37 | } 38 | async.setImmediate(fn); 39 | } 40 | } else { 41 | if (typeof link !== 'undefined') { 42 | _.set(links, pair[0], link); 43 | } 44 | async.setImmediate(fn); 45 | } 46 | }, cb); 47 | }; 48 | -------------------------------------------------------------------------------- /lib/util/handle-optional-arguments.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | 5 | /** 6 | * Handle various signatures 7 | * - {String} schemaName, {Object|Object[]} data, {Object} options, {Function} cb 8 | * - {String} schemaName, {Object|Object[]} data, {Function} cb 9 | * - {Object|Object[]} data, {Object} options, {Function} cb 10 | * - {Object|Object[]} data, {Function} cb 11 | * @param {[type]} schemaName [description] 12 | * @param {[type]} obj [description] 13 | * @param {[type]} options [description] 14 | * @param {Function} cb [description] 15 | * @return {[type]} [description] 16 | */ 17 | module.exports = function handleOptionalArguments(schemaName, data, options, cb) { 18 | if (_.isString(schemaName)) { 19 | if (_.isFunction(options)) { 20 | // signature 2 (schemaName, data, cb) 21 | cb = options; 22 | options = {}; 23 | } 24 | } else { 25 | if (_.isFunction(data)) { 26 | // signature 4 (data, cb) 27 | cb = data; 28 | data = schemaName; 29 | options = {}; 30 | schemaName = 'default'; 31 | } else { 32 | // signature 3 (data, options, cb) 33 | cb = options; 34 | options = data; 35 | data = schemaName; 36 | schemaName = 'default'; 37 | } 38 | } 39 | return { 40 | schemaName: schemaName, 41 | data: data, 42 | options: options, 43 | cb: cb 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /test/process-resource.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | chai = require('chai'), 5 | Serializer = require('../index'), 6 | _ = require('lodash'); 7 | 8 | let expect = chai.expect; 9 | 10 | describe('[hook] processResource', function() { 11 | let error, payload; 12 | 13 | let serializer = new Serializer(), 14 | data = [{ 15 | toJSON() { 16 | return this._attrs; 17 | }, 18 | _attrs: { 19 | name: 'Bob', 20 | secret: 'abc', 21 | public: '123' 22 | } 23 | }]; 24 | 25 | before(function(done) { 26 | async.series([ 27 | function defineType(fn) { 28 | serializer.define('test', { 29 | id: 'name', 30 | blacklist: [ 31 | 'secret' 32 | ], 33 | processResource(resource, cb) { 34 | cb(null, resource.toJSON()); 35 | } 36 | }, fn); 37 | }, 38 | 39 | function serializeData(fn) { 40 | serializer.serialize('test', data, function(e, p) { 41 | error = e; 42 | payload = p; 43 | fn(); 44 | }); 45 | } 46 | ], done); 47 | }); 48 | 49 | it('should not error', function() { 50 | expect(error).to.not.exist; 51 | }); 52 | 53 | it('should correctly serialize the data', function() { 54 | expect(payload).to.have.property('data').that.is.an('array').with.lengthOf(1); 55 | expect(payload.data[0]).to.have.property('id', 'Bob'); 56 | expect(payload.data[0]).to.have.property('attributes').that.is.an('object').with.all.keys('public'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | 27 | .DS_Store 28 | .AppleDouble 29 | .LSOverride 30 | 31 | # Icon must ends with two 32 | . 33 | Icon 34 | 35 | 36 | # Thumbnails 37 | ._* 38 | 39 | # Files that might appear on external disk 40 | .Spotlight-V100 41 | .Trashes 42 | 43 | # Directories potentially created on remote AFP share 44 | .AppleDB 45 | .AppleDesktop 46 | Network Trash Folder 47 | Temporary Items 48 | 49 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 50 | 51 | ## Directory-based project format 52 | .idea/ 53 | # if you remove the above rule, at least ignore user-specific stuff: 54 | # .idea/workspace.xml 55 | # .idea/tasks.xml 56 | # and these sensitive or high-churn files: 57 | # .idea/dataSources.ids 58 | # .idea/dataSources.xml 59 | # .idea/sqlDataSources.xml 60 | # .idea/dynamic.xml 61 | 62 | ## File-based project format 63 | *.ipr 64 | *.iws 65 | *.iml 66 | 67 | ## Additional for IntelliJ 68 | out/ 69 | 70 | # generated by mpeltonen/sbt-idea plugin 71 | .idea_modules/ 72 | 73 | # generated by JIRA plugin 74 | atlassian-ide-plugin.xml 75 | 76 | # generated by Crashlytics plugin (for Android Studio and Intellij) 77 | com_crashlytics_export_strings.xml 78 | 79 | # Windows image file caches 80 | Thumbs.db 81 | ehthumbs.db 82 | 83 | # Folder config file 84 | Desktop.ini 85 | 86 | # Recycle Bin used on file shares 87 | $RECYCLE.BIN/ 88 | 89 | # Windows Installer files 90 | *.cab 91 | *.msi 92 | *.msm 93 | *.msp 94 | 95 | *~ 96 | 97 | # KDE directory preferences 98 | .directory 99 | -------------------------------------------------------------------------------- /lib/util/deserialize-relationship.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | joi = require('joi'), 5 | _ = require('lodash'); 6 | 7 | function wrapError(status, title, cb) { 8 | return function(err) { 9 | if (err) { 10 | return cb(_.extend({ 11 | status: status, 12 | title: title, 13 | }, {detail: _.get(err, 'message')})); 14 | } 15 | let args = []; 16 | for (var i = 0; i < arguments.length; i++) { 17 | args.push(arguments[i]); 18 | } 19 | cb.apply(null, args); 20 | }; 21 | } 22 | 23 | module.exports = function(internal, deserialized, attr, relationship, data, cb) { 24 | async.auto({ 25 | validated: function(fn) { 26 | let dataSchema = joi.object({ 27 | id: joi.any(), 28 | type: joi.string().required(), 29 | attributes: joi.object() 30 | }).unknown(false); 31 | let relationshipSchema = joi.object({ 32 | data: joi.alternatives().try( 33 | dataSchema, 34 | joi.array().items(dataSchema) 35 | ) 36 | }).unknown(true).required(); 37 | joi.validate(relationship, relationshipSchema, {}, wrapError(400, 'Invalid Relationship', fn)); 38 | }, 39 | 40 | deserialized: ['validated', function(fn) { 41 | let rels = relationship.data, 42 | isArray = _.isArray(rels); 43 | if (!isArray) { 44 | rels = [rels]; 45 | } else { 46 | _.extend(deserialized, { 47 | [attr]: [] 48 | }); 49 | } 50 | async.eachSeries(rels, function(rel, _fn) { 51 | let relType = rel.type, 52 | relId = rel.id, 53 | deserializedRel = rel.attributes || {}; 54 | if (relId) { 55 | let idParam = _.get(internal.types, relType + '.default.id') || 'id'; 56 | deserializedRel[idParam] = relId; 57 | } 58 | if (!data[relType]) { 59 | data[relType] = []; 60 | } 61 | if (!_.find(data[relType], deserializedRel)) { 62 | data[relType].push(deserializedRel); 63 | } 64 | if (isArray) { 65 | deserialized[attr].push(deserializedRel); 66 | } else { 67 | deserialized[attr] = deserializedRel; 68 | } 69 | _fn(); 70 | }, fn); 71 | }] 72 | }, cb); 73 | }; 74 | -------------------------------------------------------------------------------- /lib/util/deserialize-resource.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | deserializeRelationship = require('./deserialize-relationship'), 5 | joi = require('joi'), 6 | _ = require('lodash'); 7 | 8 | module.exports = function(internal, resource, data, cb) { 9 | let type; 10 | async.auto({ 11 | validated: function(fn) { 12 | let schema = joi.object({ 13 | id: joi.any(), 14 | type: joi.string().required(), 15 | attributes: joi.object(), 16 | relationships: joi.object(), 17 | links: joi.object(), 18 | meta: joi.object() 19 | }).required(); 20 | 21 | joi.validate(resource, schema, {}, function(err) { 22 | if (err) { 23 | return fn({ 24 | status: 400, 25 | title: 'Invalid `resource` argument', 26 | detail: err.message, 27 | meta: { 28 | resource: resource 29 | } 30 | }); 31 | } 32 | fn(); 33 | }); 34 | }, 35 | 36 | deserialize: ['validated', function(fn) { 37 | type = resource.type; 38 | let deserialized = resource.attributes || resource.id; 39 | if (resource.id && _.isPlainObject(deserialized)) { 40 | let idParam = _.get(internal.types, type + '.default.id') || 'id'; 41 | deserialized[idParam] = resource.id; 42 | } 43 | async.setImmediate(function() { 44 | fn(null, deserialized); 45 | }); 46 | }], 47 | 48 | relationships: ['deserialize', function(fn, r) { 49 | if (!_.has(resource, 'relationships')) { 50 | return fn(); 51 | } 52 | let relationships = Object.keys(resource.relationships); 53 | async.eachSeries(relationships, function(rel, _fn) { 54 | let relationship = resource.relationships[rel]; 55 | deserializeRelationship(internal, r.deserialize, rel, relationship, data, _fn); 56 | }, fn); 57 | }], 58 | 59 | addToData: ['relationships', function(fn, r) { 60 | if (!_.has(data, type)) { 61 | data[type] = r.deserialize; 62 | } else if (!_.isArray(data[type])) { 63 | let member = data[type]; 64 | data[type] = []; 65 | data[type].push.apply(data[type], [member, r.deserialize]); 66 | } else { 67 | data[type].push(r.deserialize); 68 | } 69 | fn(); 70 | }] 71 | }, cb); 72 | }; 73 | -------------------------------------------------------------------------------- /test/synchronous.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const async = require('async'); 4 | const expect = require('chai').expect; 5 | const Serializer = require('../index'); 6 | 7 | describe('synchronous hooks', function() { 8 | let serializer = new Serializer({ 9 | baseUrl: 'https://www.example.com', 10 | links: { 11 | self(resource, options) { 12 | return options.baseUrl + options.requestPath + '/' + resource.id; 13 | } 14 | }, 15 | meta: { 16 | nickname(resource, options) { 17 | return 'lil ' + resource.attributes.first; 18 | } 19 | }, 20 | topLevelLinks: { 21 | self(options) { 22 | return options.baseUrl + options.requestPath; 23 | } 24 | }, 25 | topLevelMeta: { 26 | random(options) { 27 | return Math.random(); 28 | } 29 | } 30 | }); 31 | 32 | before(function(done) { 33 | let types = { 34 | user: { 35 | requestPath: '/api/users' 36 | } 37 | }; 38 | async.each(Object.keys(types), function(type, fn) { 39 | let config = types[type]; 40 | serializer.define(type, config, fn); 41 | }, done); 42 | }); 43 | 44 | it('should allow hooks to be synchronous', function(done) { 45 | let data = [{ 46 | id: 1, 47 | first: 'bob', 48 | last: 'smith', 49 | email: 'bsmith@example.com' 50 | }, { 51 | id: 2, 52 | first: 'susan', 53 | last: 'jones', 54 | email: 'sjones@example.com' 55 | }]; 56 | serializer.serialize('user', data, function(err, serialized) { 57 | expect(err).to.not.exist; 58 | expect(serialized).to.be.an('object'); 59 | expect(serialized).to.have.property('meta').that.is.an('object').with.property('random').that.is.a('number'); 60 | expect(serialized).to.have.property('links').that.is.an('object').with.property('self', 'https://www.example.com/api/users'); 61 | expect(serialized).to.have.property('data').that.is.an('array').with.lengthOf(2); 62 | serialized.data.forEach(function(user) { 63 | expect(user).to.be.an('object'); 64 | expect(user).to.have.property('type', 'user'); 65 | expect(user).to.have.property('attributes').that.is.an('object'); 66 | expect(user).to.have.property('links').that.is.an('object').with.property('self', `https://www.example.com/api/users/${user.id}`); 67 | expect(user).to.have.property('meta').that.is.an('object').with.property('nickname', `lil ${user.attributes.first}`); 68 | }); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/is-an-id.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | expect = require('chai').expect, 5 | mongoose = require('mongoose'), 6 | ObjectId = require('mongodb').ObjectId, 7 | Serializer = require('../index'), 8 | _ = require('lodash'); 9 | 10 | describe('mongoose', function() { 11 | let User = mongoose.model('User', new mongoose.Schema({}, {strict: false})), 12 | users = [ 13 | new User({ 14 | first: 'donald', 15 | last: 'trump', 16 | comments: [ 17 | new ObjectId(), 18 | new ObjectId() 19 | ] 20 | }), 21 | new User({ 22 | first: 'bernie', 23 | last: 'sanders' 24 | }) 25 | ], 26 | serializer; 27 | 28 | before(function(done) { 29 | serializer = new Serializer(); 30 | serializer.on('error', function(err) { 31 | console.error(err); 32 | }); 33 | async.parallel({ 34 | users: function(fn) { 35 | serializer.define('users', { 36 | processResource(resource, cb) { 37 | if (_.isFunction(resource.toObject)) { 38 | resource = resource.toObject({getters: true}); 39 | } 40 | cb(null, resource); 41 | }, 42 | links: { 43 | self(resource, options, cb) { 44 | let link = 'https://www.example.com/api/users/' + resource.id; 45 | cb(null, link); 46 | } 47 | }, 48 | relationships: { 49 | comments: { 50 | type: 'comments', 51 | include: true 52 | } 53 | }, 54 | topLevelLinks: { 55 | self(options, cb) { 56 | let link = 'https://www.example.com/api/users'; 57 | cb(null, link); 58 | } 59 | } 60 | }, fn); 61 | }, 62 | comments: function(fn) { 63 | serializer.define('comments', { 64 | processResource(resource, cb) { 65 | if (_.isFunction(resource.toObject)) { 66 | resource = resource.toObject({getters: true}); 67 | } 68 | cb(null, resource); 69 | }, 70 | links: { 71 | self(resource, options, cb) { 72 | let link = 'https://www.example.com/api/comments/' + resource.id; 73 | cb(null, link); 74 | } 75 | }, 76 | relationships: { 77 | author: { 78 | type: 'comments', 79 | include: true 80 | } 81 | }, 82 | topLevelLinks: { 83 | self(options, cb) { 84 | let link = 'https://www.example.com/api/comments'; 85 | cb(null, link); 86 | } 87 | } 88 | }, fn); 89 | } 90 | }, done); 91 | 92 | }); 93 | 94 | it('should correctly serialize the data', function(done) { 95 | serializer.serialize('users', users, function(err, payload) { 96 | expect(err).to.not.exist; 97 | expect(payload).to.contain.all.keys('data', 'links', 'included', 'meta'); 98 | expect(payload.data).to.be.an('array').with.lengthOf(2); 99 | payload.data.forEach(function(resource) { 100 | expect(resource).to.have.property('id').that.is.a('string'); 101 | expect(resource).to.have.property('attributes').that.is.an('object'); 102 | expect(resource.attributes).to.contain.all.keys('first', 'last'); 103 | }); 104 | done(err); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/complex.test.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var async = require('async'), 5 | chai = require('chai'), 6 | Serializer = require('../index'), 7 | _ = require('lodash'); 8 | 9 | let expect = chai.expect, 10 | serializer = new Serializer({ 11 | baseUrl: 'https://www.example.com', 12 | includeSerializationTime: true 13 | }); 14 | 15 | describe('complex tests', function() { 16 | 17 | before(function(done) { 18 | async.parallel({ 19 | states: function(fn) { 20 | serializer.define('states', { 21 | blacklist: ['capital'], 22 | relationships: { 23 | libraries: { 24 | type: 'libraries', 25 | include: true 26 | } 27 | } 28 | }, fn); 29 | }, 30 | 31 | cities: function(fn) { 32 | serializer.define('cities', { 33 | relationships: { 34 | state: { 35 | type: 'states', 36 | include: true 37 | } 38 | } 39 | }, fn); 40 | }, 41 | 42 | libraries: function(fn) { 43 | serializer.define('libraries', { 44 | id: '_id', 45 | blacklist: ['isbn'], 46 | relationships: { 47 | 'address.city': { 48 | type: 'cities', 49 | include: true 50 | }, 51 | 'address.state': { 52 | type: 'states', 53 | include: true 54 | }, 55 | books: { 56 | type: 'books', 57 | include: true 58 | } 59 | } 60 | }, fn); 61 | }, 62 | 63 | books: function(fn) { 64 | serializer.define('books', { 65 | id: '_id', 66 | relationships: { 67 | author: { 68 | type: 'authors', 69 | include: true 70 | } 71 | } 72 | }, fn); 73 | }, 74 | 75 | authors: function(fn) { 76 | serializer.define('authors', { 77 | id: '_id', 78 | relationships: { 79 | books: { 80 | type: 'books', 81 | include: true 82 | } 83 | } 84 | }, fn); 85 | } 86 | }, done); 87 | }); 88 | 89 | context('super nested', function() { 90 | let dataset = [ 91 | { 92 | _id: '54735750e16638ba1eee59cb', 93 | name: 'Lone Tree Public Library', 94 | address: { 95 | street: '293 S. 1st St', 96 | city: { 97 | id: 10, 98 | name: 'Denver', 99 | state: 36 100 | }, 101 | state: { 102 | id: 36, 103 | name: 'Colorado', 104 | latitude: -38.23097398723987, 105 | longitude: 101.234972349872398, 106 | capital: 'Denver', 107 | libraries: [ 108 | '54735750e16638ba1eee59cb', 109 | '54735750e16638ba1eee59dd', 110 | { 111 | _id: '54735750e16638ba1eee59ac', 112 | name: 'Denver Public Library', 113 | address: { 114 | street: '1001 S. Broadway', 115 | state: 36 116 | } 117 | } 118 | ] 119 | } 120 | }, 121 | books: [ 122 | { 123 | _id: '52735730e16632ba1eee62dd', 124 | title: 'Tesla, SpaceX, and the Quest for a Fantastic Future', 125 | isbn: '978-0062301239', 126 | author: { 127 | _id: '2934f384bb824a7cb7b238b8dc194a22', 128 | firstName: 'Ashlee', 129 | lastName: 'Vance', 130 | books: [ 131 | { 132 | _id: '52735730e16632ba1eee62dd', 133 | title: 'Tesla, SpaceX, and the Quest for a Fantastic Future', 134 | isbn: '978-0062301239' 135 | }, 136 | { 137 | _id: '52735730e16632ba1eee62ce' 138 | } 139 | ] 140 | } 141 | }, 142 | { 143 | _id: '52735780e16610ba1eee15cd', 144 | title: 'Steve Jobs', 145 | isbn: '978-1451648546', 146 | author: { 147 | _id: '5ed95269a8334d8a970a2bd9fa599288', 148 | firstName: 'Walter', 149 | lastName: 'Isaacson' 150 | } 151 | } 152 | ] 153 | } 154 | ]; 155 | 156 | let error, payload; 157 | before(function(done) { 158 | serializer.serialize('libraries', dataset, function(e, p) { 159 | error = e; 160 | payload = p; 161 | done(e); 162 | }); 163 | }); 164 | 165 | it('should not throw an error', function() { 166 | expect(error).to.not.exist; 167 | }); 168 | 169 | it('should include the correct related resources', function() { 170 | let types = _(payload.included).groupBy('type').mapValues(function(docs) { 171 | return docs.length; 172 | }).value(); 173 | expect(types).to.have.property('books', 2); 174 | expect(types).to.have.property('libraries', 1); 175 | expect(types).to.have.property('authors', 2); 176 | expect(types).to.have.property('cities', 1); 177 | expect(types).to.have.property('states', 1); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /lib/util/serialize-resource.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | errors = require('../errors'), 5 | applyLinks = require('./apply-links'), 6 | _ = require('lodash'); 7 | 8 | module.exports = function serializeResource(payload, data, options, cb) { 9 | let self = this; 10 | 11 | // define base resource 12 | let resource = { 13 | type: options.type, 14 | id: data[options.id || 'id'], 15 | attributes: {}, 16 | relationships: {}, 17 | links: {}, 18 | meta: {} 19 | }; 20 | 21 | async.auto({ 22 | processResource: function processResource(fn) { 23 | if (!_.isFunction(options.processResource)) { 24 | return fn(); 25 | } 26 | if (options.processResource.length === 1) { 27 | data = _.attempt(options.processResource, data); 28 | if (_.isError(data)) { 29 | return fn(data); 30 | } 31 | return fn(null, data); 32 | } 33 | options.processResource(data, function(err, processed) { 34 | if (err) { 35 | return fn(err); 36 | } 37 | data = processed; 38 | fn(null, data); 39 | }); 40 | }, 41 | 42 | // process data 43 | data: ['processResource', function processData(fn) { 44 | let isAnId = function(i) { 45 | 46 | //if you have a TYPED object (e.g. of type user -> instanceof === User) 47 | //this will return false and break this whole thing 48 | //return !_.isPlainObject(i); 49 | 50 | return !_.isObject(i); 51 | }, 52 | convertToShell = function(i) { 53 | i = { 54 | id: i 55 | }; 56 | if (options.id && options.id !== 'id') { 57 | i[options.id] = i.id; 58 | delete i.id; 59 | } 60 | return i; 61 | }; 62 | //isAnArrayOfIds = _.isArray(data) && _(data).map(isAnId).uniq().value() == [true]; 63 | if (isAnId(data)) { 64 | data = convertToShell(data); 65 | } /*else if (isAnArrayOfIds) { 66 | data = data.map(convertToShell); 67 | }*/ 68 | fn(null, data); 69 | }], 70 | 71 | // ensure id requirement met 72 | id: ['data', function(fn, r) { 73 | let id = options.id; 74 | if (_.isFunction(options.id)) { 75 | id = options.id(r.data); 76 | } 77 | id = id || 'id'; 78 | resource.id = data[id]; 79 | if (!resource.id) { 80 | return fn(errors.generateError('2001', 'Missing required `id` attribute', {options: options, data: data, id: id})); 81 | } 82 | fn(null, id); 83 | }], 84 | 85 | // serialize attributes 86 | attributes: ['data', function(fn, r) { 87 | function transform(resource, data, currentPath) { 88 | _.transform(data, function(result, value, key) { 89 | let keyPath = _.isString(currentPath) ? [currentPath, key].join('.') : key; 90 | let include = false; 91 | 92 | // if not blacklisted, include 93 | if (options.blacklist.indexOf(keyPath) === -1) { 94 | include = true; 95 | } 96 | 97 | // if explicitly whitelisted, include 98 | if (options.whitelist.indexOf(keyPath) !== -1) { 99 | include = true; 100 | } else if (options.whitelist.length) { 101 | // if whitelist exists, and not specified exclude 102 | include = false; 103 | } 104 | 105 | // ignore relationships and primary key 106 | if (options.relationships[keyPath] || keyPath === r.id) { 107 | include = false; 108 | } 109 | 110 | if (include) { 111 | if (_.isPlainObject(value)) { 112 | transform(result, value, keyPath); 113 | } else { 114 | _.set(result.attributes, keyPath, value); 115 | } 116 | } 117 | }, resource); 118 | } 119 | transform(resource, r.data); 120 | fn(); 121 | }], 122 | 123 | // process relationships 124 | relationships: ['data', function(fn, r) { 125 | let relationshipNames = _.keys(options.relationships); 126 | function pickTopLevel(rresource) { 127 | return _.pick(rresource, 'id', 'type', 'links', 'meta'); 128 | } 129 | 130 | async.each(relationshipNames, function(relationshipName, _fn) { 131 | // ignore if the relationship is not present 132 | let relationshipData = _.get(r.data, relationshipName); 133 | if (_.isUndefined(relationshipData)) { 134 | return _fn(); 135 | } 136 | 137 | let include = options.relationships[relationshipName].include || true, 138 | strip = false; 139 | 140 | // if backlisted, exclude 141 | if (options.blacklist.indexOf(relationshipName) !== -1) { 142 | include = false; 143 | strip = true; 144 | } 145 | 146 | // if explicitly whitelisted, include 147 | if (options.whitelist.indexOf(relationshipName) !== -1) { 148 | include = true; 149 | strip = false; 150 | } else if (options.whitelist.length) { 151 | // if whitelist exists, and not specified exclude 152 | include = false; 153 | strip = true; 154 | } 155 | 156 | let relationshipConfig = options.relationships[relationshipName], 157 | rtype = relationshipConfig.type, 158 | rschema = relationshipConfig.schema || 'default', 159 | roptions = _.merge({}, _.omit(relationshipConfig, ['type', 'include', 'links'])); 160 | 161 | self._serialize(rtype, rschema, relationshipData, roptions, function(err, rpayload) { 162 | if (err) { 163 | return _fn(err); 164 | } 165 | 166 | if (!strip) { 167 | if (_.isArray(rpayload.data)) { 168 | resource.relationships[relationshipName] = _.extend({ 169 | links: {}, 170 | meta: {}, 171 | data: _.map(rpayload.data, pickTopLevel) 172 | }, _.pick(rpayload, ['links', 'meta'])); 173 | } else { 174 | resource.relationships[relationshipName] = _.extend({ 175 | links: {}, 176 | meta: {}, 177 | data: pickTopLevel(rpayload.data) 178 | }, _.pick(rpayload, ['links', 'meta'])); 179 | } 180 | } 181 | 182 | function isPopulated(data) { 183 | return _.keys(data.attributes || {}).length > 0; 184 | } 185 | 186 | if (include) { 187 | if (!_.isArray(rpayload.data)) { 188 | rpayload.data = [rpayload.data]; 189 | } 190 | rpayload.data = rpayload.data.filter(isPopulated); 191 | payload.included.push.apply(payload.included, rpayload.data.concat(rpayload.included)); 192 | } 193 | 194 | async.parallel([ 195 | function(__fn) { 196 | if (!resource.relationships[relationshipName]) { 197 | return __fn(); 198 | } 199 | applyLinks(relationshipConfig.links || {}, resource.relationships[relationshipName].links, resource, options, __fn); 200 | }, 201 | 202 | function(__fn) { 203 | if (!resource.relationships[relationshipName]) { 204 | return __fn(); 205 | } 206 | applyLinks(relationshipConfig.meta || {}, resource.relationships[relationshipName].meta, resource, options, __fn); 207 | } 208 | ], _fn); 209 | 210 | }); 211 | }, fn); 212 | }], 213 | 214 | links: ['id', 'attributes', function applyResourceLinks(fn, r) { 215 | applyLinks(options.links, resource.links, resource, options, fn); 216 | }], 217 | 218 | meta: ['id', 'attributes', function applyResourceMeta(fn, r) { 219 | applyLinks(options.meta || {}, resource.meta, resource, options, fn); 220 | }], 221 | 222 | payload: ['id', 'attributes', function addToPayload(fn) { 223 | if (options.isCollection) { 224 | payload.data.push(resource); 225 | } else { 226 | payload.data = resource; 227 | } 228 | fn(); 229 | }] 230 | }, function(err) { 231 | cb(err); 232 | }); 233 | }; 234 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | errors = require('./errors'), 5 | EventEmitter = require('events').EventEmitter, 6 | joi = require('joi'), 7 | util = require('./util'), 8 | nodeUtil = require('util'), 9 | _ = require('lodash'); 10 | 11 | function Serializer(options) { 12 | let self = this; 13 | EventEmitter.call(self); 14 | 15 | let internal = { 16 | /** 17 | * Global options 18 | * @type {Object} 19 | */ 20 | options: _.isPlainObject(options) ? _.cloneDeep(options) : {}, 21 | 22 | /** 23 | * Type storage 24 | * @type {Object} 25 | */ 26 | types: {} 27 | }; 28 | 29 | 30 | self.define = function(type, schemaName, options, cb) { 31 | if (_.isObject(schemaName)) { 32 | cb = options; 33 | options = schemaName; 34 | schemaName = 'default'; 35 | } 36 | 37 | util.validateOptions(options, function(err, validated) { 38 | if (err) { 39 | self.emit('error', err); 40 | return cb(err); 41 | } 42 | _.extend(validated, { 43 | type: type, 44 | schema: schemaName 45 | }); 46 | _.set(internal.types, [type, schemaName].join('.'), validated); 47 | cb(); 48 | }); 49 | }; 50 | 51 | 52 | self.deserialize = function(payload, cb) { 53 | let data = {}; 54 | async.auto({ 55 | validate: function validatePayload(fn) { 56 | let schema = joi.object({ 57 | meta: joi.object(), 58 | links: joi.object(), 59 | data: joi.alternatives().try( 60 | joi.object(), 61 | joi.array().items(joi.object()) 62 | ).allow(null).required() 63 | }).required(); 64 | joi.validate(payload, schema, {}, function(err) { 65 | if (err) { 66 | return cb({ 67 | status: 400, 68 | title: 'Invalid `payload` provided to #deserialize()', 69 | meta: { 70 | payload: payload 71 | } 72 | }); 73 | } 74 | fn(); 75 | }); 76 | }, 77 | 78 | deserialize: ['validate', function deserializeData(fn) { 79 | if (_.isPlainObject(payload.data)) { 80 | payload.data = [payload.data]; 81 | } 82 | async.eachSeries(payload.data, function(resource, _fn) { 83 | util.deserializeResource(internal, resource, data, _fn); 84 | }, fn); 85 | }] 86 | }, function(err) { 87 | if (err) { 88 | return cb(err); 89 | } 90 | cb(null, data); 91 | }); 92 | }; 93 | 94 | 95 | self.serialize = function(type, schemaName, data, options, cb) { 96 | var start = process.hrtime(); 97 | 98 | function getElapsedTime() { 99 | var precision = 3; // 3 decimal places 100 | var elapsed = process.hrtime(start)[1] / 1000000; // divide by a million to get nano to milli 101 | return process.hrtime(start)[0] + "s, " + elapsed.toFixed(precision) + "ms"; 102 | } 103 | 104 | // process function signature 105 | let args = util.handleOptionalArguments(schemaName, data, options, cb); 106 | schemaName = args.schemaName; 107 | data = args.data; 108 | options = args.options; 109 | cb = args.cb; 110 | 111 | self._serialize(type, schemaName, data, options, function(err, payload) { 112 | if (err) { 113 | _.set(err, 'meta.serializationTime', getElapsedTime()); 114 | return cb(err); 115 | } 116 | /* 117 | payload.included = _.uniqBy(payload.included, function(item) { 118 | return item.type + item.id; 119 | }); 120 | */ 121 | payload.included = _.reduce(payload.included, function(memo, includedResource, i) { 122 | let ii = _.findIndex(memo, {type: includedResource.type, id: includedResource.id}); 123 | if (ii === -1) { 124 | if (includedResource.type === type) { 125 | if (_.isArray(payload.data)) { 126 | if (_.find(payload.data, {id: includedResource.id})) { 127 | return memo; 128 | } 129 | } else if (payload.data.id === includedResource.id) { 130 | return memo; 131 | } 132 | } 133 | memo.push(includedResource); 134 | return memo; 135 | } 136 | let alreadyIncludedResource = memo[ii]; 137 | _.extend(alreadyIncludedResource, includedResource); 138 | memo[ii] = alreadyIncludedResource; 139 | return memo; 140 | }, []); 141 | if (internal.options.includeSerializationTime === true || options.includeSerializationTime === true) { 142 | _.set(payload, 'meta.serializationTime', getElapsedTime()); 143 | } 144 | 145 | return cb(null, payload); 146 | }); 147 | }; 148 | 149 | self._serialize = function(type, schemaName, data, options, cb) { 150 | // ensure the type and schema are defined 151 | let typePath = [type, schemaName].join('.'), 152 | schemaOptions = _.get(internal.types, typePath); 153 | if (!schemaOptions) { 154 | cb(errors.generateError('1002', 'No type defined for `' + typePath + '`', {options: options})); 155 | } 156 | 157 | // merge schema options with default options if available 158 | if (schemaName !== 'default') { 159 | let defaultPath = [type, 'default'].join('.'), 160 | defaulSchemaOptions = _.get(internal.types, defaultPath); 161 | if (defaulSchemaOptions) { 162 | schemaOptions = _.merge({}, defaulSchemaOptions, schemaOptions); 163 | } 164 | } 165 | 166 | // define initial payload and options 167 | let payload = { 168 | links: {}, 169 | data: null, 170 | included: [], 171 | meta: {} 172 | }; 173 | 174 | // define options for this request 175 | options = _.omit(options, ['type', 'schema']); 176 | options = _.merge({}, internal.options, schemaOptions, options); 177 | 178 | async.auto({ 179 | options: function validateOptions(fn) { 180 | util.validateOptions(options, function(err, validated) { 181 | if (err) { 182 | return fn(err); 183 | } 184 | options = validated; 185 | fn(); 186 | }); 187 | }, 188 | 189 | // allow for preproccessing of a collection 190 | data: ['options', function processData(fn) { 191 | if (_.isArray(data) && _.isFunction(options.processCollection)) { 192 | return options.processCollection(data, options, fn); 193 | } 194 | fn(null, data); 195 | }], 196 | 197 | // serialize the data 198 | serialized: ['data', function serializeData(fn, r) { 199 | if (!_.isArray(r.data)) { 200 | options.isCollection = false; 201 | return util.serializeResource.call(self, payload, r.data, options, fn); 202 | } 203 | payload.data = []; 204 | options.isCollection = true; 205 | async.each(r.data, function(doc, _fn) { 206 | util.serializeResource.call(self, payload, doc, options, _fn); 207 | }, fn); 208 | }], 209 | 210 | topLevelLinks: ['data', function applyTopLevelLinks(fn) { 211 | util.applyLinks(options.topLevelLinks, payload.links, options, fn); 212 | }], 213 | 214 | meta: ['data', function applyTopLevelMeta(fn) { 215 | util.applyLinks(options.topLevelMeta, payload.meta, options, fn); 216 | }] 217 | }, function(err, results) { 218 | if (err) { 219 | payload = self.serializeError(err); 220 | self.emit('error', err); 221 | return cb(payload); 222 | } 223 | cb(null, payload); 224 | }); 225 | }; 226 | 227 | self.serializeError = function(err, meta, statusCode) { 228 | if (_.isNumber(meta)) { 229 | statusCode = meta; 230 | meta = {}; 231 | } 232 | if (!_.isPlainObject(meta)) { 233 | meta = {}; 234 | } 235 | statusCode = _.isNumber(statusCode) ? statusCode : 500; 236 | statusCode = statusCode.toString(); 237 | let payload = { 238 | errors: [] 239 | }; 240 | 241 | function convertToJsonApiError(err) { 242 | if (!(err instanceof Error)) { 243 | let errorSchema = joi.object({ 244 | id: joi.alternatives().try(joi.string(), joi.number()), 245 | links: joi.object({ 246 | about: joi.alternatives().try( 247 | joi.string(), 248 | joi.object({ 249 | href: joi.string(), 250 | meta: joi.object() 251 | }) 252 | ) 253 | }), 254 | status: joi.alternatives().try(joi.string(), joi.number().integer()), 255 | code: joi.string(), 256 | title: joi.string(), 257 | detail: joi.string(), 258 | source: joi.object({ 259 | pointer: joi.string(), 260 | parameter: joi.string() 261 | }), 262 | meta: joi.object() 263 | }).required(); 264 | 265 | let result = joi.validate(err, errorSchema, {convert: true}); 266 | if (!result.error) { 267 | result.value.status = result.value.status.toString(); 268 | return result.value; 269 | } 270 | } else { 271 | err = err.toString(); 272 | } 273 | 274 | let error = { 275 | status: statusCode, 276 | detail: err.message || 'Undefined error occurred', 277 | meta: _.merge(meta, { 278 | error: err 279 | }) 280 | }; 281 | 282 | return error; 283 | } 284 | 285 | if (_.isPlainObject(err) && _.isArray(err.errors)) { 286 | err.errors = err.errors.map(convertToJsonApiError); 287 | payload = err; 288 | } else { 289 | err = convertToJsonApiError(err); 290 | payload.errors.push(err); 291 | } 292 | 293 | // determine appropriate status 294 | let statuses = payload.errors.map(function(e) { 295 | return parseInt(e.status || 500); 296 | }), 297 | sortFunction = function(pair, pair2) { 298 | return _.lte(pair[1], pair2[1]); 299 | }; 300 | 301 | statuses = _(statuses).groupBy(function(value) { 302 | return value; 303 | }).mapValues(function(value) { 304 | return value.length; 305 | }).toPairs().value(); 306 | 307 | let status = parseInt(_.first(statuses.sort(_.lte))[0]); 308 | 309 | payload.meta = { 310 | status: _.isInteger(status) ? status : 500 311 | }; 312 | return payload; 313 | }; 314 | 315 | self._validateError = function(err) { 316 | try { 317 | let errorSchema = joi.object({ 318 | id: joi.alternatives().try(joi.string(), joi.number()), 319 | links: joi.object({ 320 | about: joi.alternatives().try( 321 | joi.string(), 322 | joi.object({ 323 | href: joi.string(), 324 | meta: joi.object() 325 | }) 326 | ) 327 | }), 328 | status: joi.string(), 329 | code: joi.string(), 330 | title: joi.string(), 331 | detail: joi.string(), 332 | source: joi.object({ 333 | pointer: joi.string(), 334 | parameter: joi.string() 335 | }), 336 | meta: joi.object() 337 | }).required(); 338 | joi.assert(err, errorSchema); 339 | return true; 340 | } catch (e) { 341 | return false; 342 | } 343 | }; 344 | 345 | return self; 346 | } 347 | 348 | nodeUtil.inherits(Serializer, EventEmitter); 349 | 350 | module.exports = Serializer; 351 | -------------------------------------------------------------------------------- /test/deserialize.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const async = require('async'); 4 | const expect = require('chai').expect; 5 | const Serializer = require('../index'); 6 | const _ = require('lodash'); 7 | 8 | describe('deserialize', function() { 9 | it('should deserialize the payload successfully', function(done) { 10 | async.parallel([ 11 | function(fn) { 12 | async.waterfall([ 13 | function(_fn) { 14 | let serializer = new Serializer(); 15 | async.parallel([ 16 | function(__fn) { 17 | serializer.define('photos', {}, __fn); 18 | }, 19 | 20 | function(__fn) { 21 | serializer.define('people', { 22 | id: '_id' 23 | }, __fn); 24 | } 25 | ], function(err) { 26 | if (err) { 27 | return _fn(err); 28 | } 29 | _fn(null, serializer); 30 | }); 31 | }, 32 | 33 | function(serializer, _fn) { 34 | let payload = { 35 | data: { 36 | type: 'photos', 37 | attributes: { 38 | title: 'Ember Hamster', 39 | src: 'http://example.com/images/productivity.png' 40 | }, 41 | relationships: { 42 | photographer: { 43 | data: { 44 | type: 'people', 45 | id: '9' 46 | } 47 | } 48 | } 49 | } 50 | }; 51 | serializer.deserialize(payload, function(err, data) { 52 | expect(err).to.not.exist; 53 | expect(data).to.be.an('object'); 54 | expect(data).to.have.property('photos').that.is.an('object'); 55 | expect(data.photos).to.have.all.keys('title', 'src', 'photographer'); 56 | expect(data.photos.photographer).to.be.an('object').with.property('_id', '9'); 57 | expect(data).to.have.property('people').that.is.an('array'); 58 | expect(data.people[0]).to.have.property('_id', '9'); 59 | _fn(err); 60 | }); 61 | } 62 | ], fn); 63 | }, 64 | 65 | function(fn) { 66 | async.waterfall([ 67 | function(_fn) { 68 | let serializer = new Serializer(); 69 | async.parallel([ 70 | function(__fn) { 71 | serializer.define('photos', {}, __fn); 72 | }, 73 | 74 | function(__fn) { 75 | serializer.define('people', { 76 | id: '_id' 77 | }, __fn); 78 | } 79 | ], function(err) { 80 | if (err) { 81 | return _fn(err); 82 | } 83 | _fn(null, serializer); 84 | }); 85 | }, 86 | 87 | function(serializer, _fn) { 88 | let payload = { 89 | data: [{ 90 | type: 'photos', 91 | attributes: { 92 | title: 'Ember Hamster', 93 | src: 'http://example.com/images/productivity.png' 94 | }, 95 | relationships: { 96 | photographer: { 97 | data: { 98 | type: 'people', 99 | id: '9' 100 | } 101 | } 102 | } 103 | }, { 104 | type: 'photos', 105 | attributes: { 106 | title: 'Sweet Photo', 107 | src: 'http://example.com/images/sweet.png' 108 | }, 109 | relationships: { 110 | photographer: { 111 | data: { 112 | type: 'people', 113 | id: '23' 114 | } 115 | }, 116 | likes: { 117 | data: [{ 118 | type: 'people', 119 | id: '9' 120 | },{ 121 | type: 'people', 122 | id: '43' 123 | }] 124 | } 125 | } 126 | }] 127 | }; 128 | serializer.deserialize(payload, function(err, data) { 129 | expect(err).to.not.exist; 130 | expect(data).to.be.an('object'); 131 | expect(data).to.have.property('photos').that.is.an('array'); 132 | expect(data.photos[0]).to.have.all.keys('title', 'src', 'photographer'); 133 | expect(data.photos[0]).to.have.property('photographer').that.is.an('object').with.property('_id', '9'); 134 | expect(data.photos[0]).to.not.have.property('likes'); 135 | expect(data.photos[1]).to.have.all.keys('title', 'src', 'photographer', 'likes'); 136 | expect(data.photos[1]).to.have.property('photographer').that.is.an('object').with.property('_id', '23'); 137 | expect(data.photos[1]).to.have.property('likes').that.is.an('array').with.lengthOf(2); 138 | let likes = _.map(data.photos[1].likes, '_id'); 139 | ['9', '43'].forEach(function(id) { 140 | expect(likes).to.contain(id); 141 | }); 142 | expect(data).to.have.property('people').that.is.an('array').with.lengthOf(3); 143 | let people = _.map(data.people, '_id'); 144 | ['9', '23', '43'].forEach(function(id) { 145 | expect(people).to.contain(id); 146 | }); 147 | _fn(err); 148 | }); 149 | } 150 | ], fn); 151 | }, 152 | 153 | function(fn) { 154 | async.waterfall([ 155 | function(_fn) { 156 | let serializer = new Serializer(); 157 | serializer.define('people', { 158 | id: '_id' 159 | }, function(err) { 160 | if (err) { 161 | return _fn(err); 162 | } 163 | _fn(null, serializer); 164 | }); 165 | }, 166 | 167 | function(serializer, _fn) { 168 | let payload = { 169 | data: { 170 | type: 'people', 171 | id: '12' 172 | } 173 | }; 174 | serializer.deserialize(payload, function(err, data) { 175 | expect(err).to.not.exist; 176 | expect(data).to.be.an('object'); 177 | expect(data).to.have.property('people', '12'); 178 | _fn(err); 179 | }); 180 | } 181 | ], fn); 182 | }, 183 | 184 | function(fn) { 185 | async.waterfall([ 186 | function(_fn) { 187 | let serializer = new Serializer(); 188 | serializer.define('people', { 189 | id: '_id' 190 | }, function(err) { 191 | if (err) { 192 | return _fn(err); 193 | } 194 | _fn(null, serializer); 195 | }); 196 | }, 197 | 198 | function(serializer, _fn) { 199 | let payload = { 200 | data: null 201 | }; 202 | serializer.deserialize(payload, function(err, data) { 203 | expect(err).to.not.exist; 204 | expect(data).to.be.an('object'); 205 | let keys = Object.keys(data); 206 | expect(keys).to.have.lengthOf(0); 207 | _fn(err); 208 | }); 209 | } 210 | ], fn); 211 | }, 212 | 213 | function(fn) { 214 | async.waterfall([ 215 | function(_fn) { 216 | let serializer = new Serializer(); 217 | serializer.define('people', { 218 | id: '_id' 219 | }, function(err) { 220 | if (err) { 221 | return _fn(err); 222 | } 223 | _fn(null, serializer); 224 | }); 225 | }, 226 | 227 | function(serializer, _fn) { 228 | let payload = { 229 | data: [] 230 | }; 231 | serializer.deserialize(payload, function(err, data) { 232 | expect(err).to.not.exist; 233 | expect(data).to.be.an('object'); 234 | let keys = Object.keys(data); 235 | expect(keys).to.have.lengthOf(0); 236 | _fn(err); 237 | }); 238 | } 239 | ], fn); 240 | }, 241 | 242 | function(fn) { 243 | async.waterfall([ 244 | function(_fn) { 245 | let serializer = new Serializer(); 246 | serializer.define('people', { 247 | id: '_id' 248 | }, function(err) { 249 | if (err) { 250 | return _fn(err); 251 | } 252 | _fn(null, serializer); 253 | }); 254 | }, 255 | 256 | function(serializer, _fn) { 257 | let payload = { 258 | data: [ 259 | {type: 'people', id: '2'}, 260 | {type: 'people', id: '3'} 261 | ] 262 | }; 263 | serializer.deserialize(payload, function(err, data) { 264 | expect(err).to.not.exist; 265 | expect(data).to.be.an('object'); 266 | expect(data).to.have.property('people').that.is.an('array'); 267 | expect(data.people).to.eql(['2', '3']); 268 | _fn(err); 269 | }); 270 | } 271 | ], fn); 272 | } 273 | ], done); 274 | }); 275 | 276 | it('should error when appropriate', function(done) { 277 | async.parallel([ 278 | function(fn) { 279 | async.waterfall([ 280 | function(_fn) { 281 | let serializer = new Serializer(); 282 | async.parallel([ 283 | function(__fn) { 284 | serializer.define('user', {}, __fn); 285 | }, 286 | function(__fn) { 287 | serializer.define('group', {}, __fn); 288 | } 289 | ], function(err) { 290 | if (err) { 291 | return _fn(err); 292 | } 293 | _fn(null, serializer); 294 | }); 295 | }, 296 | 297 | function(serializer, _fn) { 298 | let badPayload = { 299 | data: { 300 | attributes: { 301 | first: 'bob', 302 | last: 'smith' 303 | }, 304 | relationships: { 305 | groups: { 306 | data: [{id: 1}] 307 | } 308 | } 309 | } 310 | }; 311 | serializer.deserialize(badPayload, function(err) { 312 | expect(err).to.exist; 313 | _fn(null, serializer); 314 | }); 315 | }, 316 | 317 | function(serializer, _fn) { 318 | let badPayload = { 319 | meta: { 320 | something: 'test', 321 | somethingElse: 'test' 322 | } 323 | }; 324 | serializer.deserialize(badPayload, function(err) { 325 | expect(err).to.exist; 326 | _fn(null, serializer); 327 | }); 328 | }, 329 | 330 | function(serializer, _fn) { 331 | let badPayload = { 332 | data: { 333 | type: 'user', 334 | attributes: { 335 | first: 'bob', 336 | last: 'smith' 337 | }, 338 | relationships: { 339 | groups: { 340 | data: [{id: 1}] 341 | } 342 | } 343 | } 344 | }; 345 | serializer.deserialize(badPayload, function(err) { 346 | expect(err).to.exist; 347 | _fn(null, serializer); 348 | }); 349 | } 350 | ], fn); 351 | } 352 | ], done); 353 | }); 354 | 355 | it('should deserialize whole relationships', function(done) { 356 | let serializer = new Serializer(); 357 | async.waterfall([ 358 | function(fn) { 359 | let payload = { 360 | data: { 361 | type: 'email', 362 | attributes: { 363 | from_email: 'noreply@example.com', 364 | from_name: 'Joe Bob', 365 | subject: 'Test Email', 366 | template_name: 'basic' 367 | }, 368 | relationships: { 369 | recipients: { 370 | data: [{ 371 | type: 'to', 372 | attributes: { 373 | email: 'sam@example.com', 374 | first: 'Sam' 375 | } 376 | }, { 377 | type: 'to', 378 | attributes: { 379 | email: 'sue@example.com', 380 | first: 'Sue' 381 | } 382 | }, { 383 | type: 'cc', 384 | attributes: { 385 | email: 'william@example.com', 386 | first: 'Bill' 387 | } 388 | }] 389 | } 390 | } 391 | } 392 | }; 393 | serializer.deserialize(payload, function(err, data) { 394 | expect(err).to.not.exist; 395 | expect(data).to.have.all.keys('email', 'to', 'cc'); 396 | fn(err); 397 | }); 398 | } 399 | ], done); 400 | }); 401 | }); 402 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-api-ify 2 | [![Build Status](https://travis-ci.org/kutlerskaggs/json-api-ify.svg?branch=master)](https://travis-ci.org/kutlerskaggs/json-api-ify) 3 | 4 | 5 | a `node.js` library for serializing your data to [JSON API v1.0](http://jsonapi.org/) compliant documents, inspired by [jsonapi-serializer](https://github.com/SeyZ/jsonapi-serializer). this library makes no assumptions regarding your choice of ORM/ODM, or the structure of your data. simply define your types and how their related and let this library do the heavy lifting. 6 | 7 | 8 | ## Installing 9 | ```bash 10 | npm install --save json-api-ify 11 | ``` 12 | 13 | 14 | ## Getting Started 15 | Create a new *reusable* serializer. 16 | ```javascript 17 | var Serializer = require('json-api-ify'); 18 | 19 | let serializer = new Serializer({ 20 | baseUrl: 'https://www.example.com/api', 21 | topLevelMeta: { 22 | 'api-version': 'v1.0.0' 23 | } 24 | }); 25 | ``` 26 | 27 | 28 | Define a type. *(read more about options below)* 29 | ```javascript 30 | serializer.define('users', { 31 | id: '_id', 32 | blacklist: [ 33 | 'password', 34 | 'phone.mobile' 35 | ], 36 | links: { 37 | self(resource, options, cb) { 38 | let link = options.baseUrl + '/users/' + resource.id; 39 | cb(null, link); 40 | } 41 | }, 42 | meta: { 43 | nickname(resource, options, cb) { 44 | let nickname = 'lil ' + resource.attributes.first; 45 | cb(null, nickname); 46 | } 47 | }, 48 | processResource(resource, cb) { 49 | return cb(null, resource.toObject()); 50 | }, 51 | topLevelLinks: { 52 | self(options, cb) { 53 | let link = options.baseUrl + '/users'; 54 | cb(null, link); 55 | }, 56 | next(options, cb) { 57 | let link = options.baseUrl + '/users'; 58 | if (options.nextPage) { 59 | link += '?page=' + options.nextPage; 60 | } 61 | cb(null, link); 62 | } 63 | }, 64 | topLevelMeta: { 65 | total(options, cb) { 66 | cb(null, options.total); 67 | } 68 | } 69 | }, function(err) { 70 | // check for definition errors 71 | }) 72 | ``` 73 | 74 | Get a hold of some data that needs to be serialized. 75 | ```javascript 76 | let data = [new User({ 77 | first: 'Kendrick', 78 | last: 'Lamar', 79 | email: 'klamar@example.com', 80 | password: 'elkjqe0920oqhvrophepohiwveproihgqp398yr9pq8gehpqe9rf9q8er', 81 | phone: { 82 | home: '+18001234567', 83 | mobile: '+180045678910' 84 | }, 85 | address: { 86 | addressLine1: '406 Madison Court', 87 | zipCode: '49426', 88 | country: 'USA' 89 | } 90 | }), new User({ 91 | first: 'Kanye', 92 | last: 'West', 93 | email: 'kwest@example.com', 94 | password: 'asdlkj2430r3r0ghubwf9u3rbg9u3rbgi2q3oubgoubeqfnpviquberpibq', 95 | phone: { 96 | home: '+18002345678', 97 | mobile: '+18007890123' 98 | }, 99 | address: { 100 | addressLine1: '361 Shady Lane', 101 | zipCode: '23185', 102 | country: 'USA' 103 | } 104 | })]; 105 | ``` 106 | 107 | Serialize it 108 | ```javascript 109 | serializer.serialize('users', data, function(err, payload) { 110 | console.log(payload); 111 | }); 112 | ``` 113 | 114 | Or, use it in a route 115 | ```javascript 116 | function(req, res) { 117 | async.auto({ 118 | users: function findUsers(fn) { 119 | User.find({}) 120 | .limit(10) 121 | .skip(parseInt(req.query.page || 0) * 10) 122 | .exec(fn); 123 | }, 124 | 125 | count: function countUsers(fn) { 126 | User.count({}).exec(fn); 127 | }, 128 | 129 | payload: ['users', 'count', function serialize(fn, results) { 130 | serializer.serialize('users', results.users, { 131 | total: results.count, 132 | nextPage: (req.query.page || 1) + 1 133 | }, fn); 134 | }] 135 | }, function(err, payload) { 136 | if (err) { 137 | return res.json(500, {errors: [{ 138 | status: 500, 139 | detail: err.message 140 | }]}); 141 | } 142 | res.json(200, payload); 143 | }); 144 | } 145 | ``` 146 | Response body: 147 | ```json 148 | { 149 | "links": { 150 | "self": "https://www.example.com/api/users", 151 | "next": "https://www.example.com/api/users?page=2" 152 | }, 153 | "data": [ 154 | { 155 | "type": "users", 156 | "id": "54735750e16638ba1eee59cb", 157 | "attributes": { 158 | "first": "Kendrick", 159 | "last": "Lamar", 160 | "email": "klamar@example.com", 161 | "phone": { 162 | "home": "+18001234567" 163 | }, 164 | "address": { 165 | "addressLine1": "406 Madison Court", 166 | "zipCode": "49426", 167 | "country": "USA" 168 | } 169 | }, 170 | "relationships": {}, 171 | "links": { 172 | "self": "https://www.example.com/api/users/54735750e16638ba1eee59cb" 173 | }, 174 | "meta": { 175 | "nickname": "lil Kendrick" 176 | } 177 | }, 178 | { 179 | "type": "users", 180 | "id": "5490143e69e49d0c8f9fc6bc", 181 | "attributes": { 182 | "first": "Kanye", 183 | "last": "West", 184 | "email": "kwest@example.com", 185 | "phone": { 186 | "home": "+18002345678" 187 | }, 188 | "address": { 189 | "addressLine1": "361 Shady Lane", 190 | "zipCode": "23185", 191 | "country": "USA" 192 | } 193 | }, 194 | "relationships": {}, 195 | "links": { 196 | "self": "https://www.example.com/api/users/5490143e69e49d0c8f9fc6bc" 197 | }, 198 | "meta": { 199 | "nickname": "lil Kanye" 200 | } 201 | } 202 | ], 203 | "included": [], 204 | "meta": { 205 | "api-version": "v1.0.0", 206 | "total": 2 207 | } 208 | } 209 | ``` 210 | 211 | 212 | ## Schemas 213 | A type can have multiple serialization *schemas*, which you can create by calling `define` with a schema name. Any schema options provided will augment the *default* schema. 214 | ```javascript 215 | serializer.define('users', 'names-only', { 216 | whitelist: [ 217 | 'first', 218 | 'last' 219 | ] 220 | }, callback); 221 | ``` 222 | ```javascript 223 | serializer.serialize('users', 'names-only', data, function(err, payload) { 224 | console.log(payload); 225 | }); 226 | ``` 227 | ```json 228 | { 229 | "links": { 230 | "self": "https://www.example.com/api/users" 231 | }, 232 | "data": [ 233 | { 234 | "type": "users", 235 | "id": "54735750e16638ba1eee59cb", 236 | "attributes": { 237 | "first": "Kendrick", 238 | "last": "Lamar" 239 | }, 240 | "relationships": {}, 241 | "links": { 242 | "self": "https://www.example.com/api/users/54735750e16638ba1eee59cb" 243 | }, 244 | "meta": { 245 | "nickname": "lil Kendrick" 246 | } 247 | }, 248 | { 249 | "type": "users", 250 | "id": "5490143e69e49d0c8f9fc6bc", 251 | "attributes": { 252 | "first": "Kanye", 253 | "last": "West" 254 | }, 255 | "relationships": {}, 256 | "links": { 257 | "self": "https://www.example.com/api/users/5490143e69e49d0c8f9fc6bc" 258 | }, 259 | "meta": { 260 | "nickname": "lil Kanye" 261 | } 262 | } 263 | ], 264 | "included": [], 265 | "meta": { 266 | "api-version": "v1.0.0" 267 | } 268 | } 269 | ``` 270 | 271 | 272 | ## Relationships 273 | Relationships are easy as well. First, include a relationship map in your type/schema options. 274 | ```javascript 275 | serializer.define('users', { 276 | // .. 277 | relationships: { 278 | groups: { 279 | type: 'groups', 280 | include: true, 281 | links: { 282 | self(resource, options, cb) { 283 | let link = options.baseUrl + '/users/' + resource.id + '/relationships/groups'; 284 | cb(null, link); 285 | }, 286 | related(resource, options, cb) { 287 | let link = options.baseUrl + '/users/' + resource.id + '/groups'; 288 | cb(null, link); 289 | } 290 | } 291 | } 292 | } 293 | // .. 294 | }, callback); 295 | ``` 296 | Lastly, define the related type. 297 | ```javascript 298 | serializer.define('groups', { 299 | // .. 300 | relationships: { 301 | users: { 302 | type: 'users', 303 | include: true, 304 | schema: 'names-only', 305 | links: { 306 | self(resource, options, cb) { 307 | let link = options.baseUrl + '/groups/' + resource.id + '/relationships/users'; 308 | cb(null, link); 309 | }, 310 | related(resource, options, cb) { 311 | let link = options.baseUrl + '/groups/' + resource.id + '/users'; 312 | cb(null, link); 313 | } 314 | } 315 | } 316 | } 317 | // .. 318 | }, callback); 319 | ``` 320 | 321 | 322 | ## Deserialize 323 | extract the data from a payload in a slightly more usable fashion 324 | ```javascript 325 | let payload = { 326 | data: { 327 | type: 'user', 328 | attributes: { 329 | first: 'a$ap', 330 | last: 'ferg', 331 | email: 'aferg@example.com', 332 | phone: { 333 | home: '1-111-111-1111' 334 | } 335 | }, 336 | relationships: { 337 | groups: { 338 | data: [{ 339 | type: 'group', 340 | id: '56cd74546033f8d420bc1c11' 341 | },{ 342 | type: 'group', 343 | id: '56cd74546033f8d420bc1c12' 344 | }] 345 | } 346 | } 347 | } 348 | }; 349 | serializer.deserialize(payload, function(err, data) { /* .. */ }); 350 | ``` 351 | here, data would look like: 352 | ```json 353 | { 354 | "user": { 355 | "first": "a$ap", 356 | "last": "ferg", 357 | "email": "aferg@example.com", 358 | "phone": { 359 | "home": "1-111-111-1111" 360 | }, 361 | "groups": [{ 362 | "_id": "56cd74546033f8d420bc1c11" 363 | },{ 364 | "_id": "56cd74546033f8d420bc1c12" 365 | }] 366 | }, 367 | "groups": [{ 368 | "_id": "56cd74546033f8d420bc1c11" 369 | },{ 370 | "_id": "56cd74546033f8d420bc1c12" 371 | }] 372 | } 373 | ``` 374 | 375 | 376 | ## API 377 | ### Constructor Summary 378 | #### Serializer([options]) 379 | constructs a new serializer instance 380 | 381 | ###### Arguments 382 | | Param | Type | Description | 383 | | :---: | :---: | :--- | 384 | | `[options]` | `{Object}` | global options. see `serialize()` options for more detail | 385 | --- 386 | 387 | 388 | ### Method Summary 389 | #### define(type, [schema], options, callback) 390 | defines a type serialization schema 391 | 392 | ###### Arguments 393 | | Param | Type | Description | 394 | | :---: | :---: | :--- | 395 | | `type` | `{String}` | the `resource` type | 396 | | `[schema]` | `{String}` | the serialization `schema` to use. defaults to `default` | 397 | | `options` | `{Object}` | schema options | 398 | | `callback(err)` | `{Function}` | a function that receives any definition error. | 399 | --- 400 | 401 | 402 | #### deserialize(payload, callback) 403 | deserializes the *data* attribute of the payload 404 | 405 | ###### Arguments 406 | | Param | Type | Description | 407 | | :---: | :---: | :--- | 408 | | `payload` | `{Object}` | a valid JSON API payload | 409 | | `callback(err, data)` | `{Function}` | a function that receives any deserialization error and the extracted data. | 410 | --- 411 | 412 | 413 | #### serialize(type, [schema], data, [options], callback) 414 | serializes `data` into a JSON API v1.0 compliant document 415 | 416 | ###### Arguments 417 | | Param | Type | Description | 418 | | :---: | :---: | :--- | 419 | | `type` | `{String}` | the `resource` type | 420 | | `[schema]` | `{String}` | the serialization `schema` to use. defaults to `default` | 421 | | `data` | `{*}` | the `data` to serialize | 422 | | `[options]` | `{Object}` | single use options. these options will be merged with the global options, default schema options, and any applicable non-default schema options | 423 | | `callback(err, payload)` | `{Function}` | a function that receives any serialization error and JSON API document. | 424 | 425 | ###### Options 426 | ```javascript 427 | { 428 | // an array of string paths to omit from the resource, this option 429 | // includes relationships that you may wish to omit 430 | blacklist: [], 431 | 432 | // the path to the primary key on the resource 433 | id: 'id', 434 | 435 | // a map of resource links 436 | links: { 437 | // asynchronous 438 | self(resource, options, cb) { 439 | // each key can be a value to set, or asynchronous function that 440 | // receives the processed resource, serialization options, and 441 | // a callback that should pass any error and the link value 442 | cb(null, link); 443 | }, 444 | // synchronous 445 | self(resource, options) { 446 | return options.baseUrl + '/api/users/' + resource.id; 447 | } 448 | }, 449 | 450 | // a map of meta members 451 | meta: { 452 | // asynchronous 453 | self(resource, options, cb) { 454 | // each key can be a value to set, or asynchronous function that 455 | // receives the processed resource, serialization options, and 456 | // a callback that should pass any error and the meta value 457 | cb(null, meta); 458 | }, 459 | // synchronous 460 | self(resource, options) { 461 | return meta; 462 | } 463 | }, 464 | 465 | // preprocess your resources 466 | // all resources must be objects, otherwise they're assumed to be 467 | // unpopulated ids. NOTE!! If you're working with mongoose models, 468 | // unpopulated ids can be objects, so you will need to convert them 469 | // to strings 470 | processResource(resource, /* cb */) { 471 | if (typeof resource.toJSON === 'function') { 472 | resource = resource.toJSON(); 473 | } else if (resource instanceof mongoose.Types.ObjectId) { 474 | resource = resource.toString(); 475 | } 476 | return resource; 477 | }, 478 | 479 | // relationship configuration 480 | relationships: { 481 | // each key represents a resource path that points to a 482 | // nested resource or collection of nested resources 483 | 'groups': { 484 | // the type of resource 485 | type: 'groups', 486 | 487 | // whether or not to include the nested resource(s) 488 | include: true, 489 | 490 | // optionally specify a non-default schema to use 491 | schema: 'my-schema', 492 | 493 | // a map of links to define on the relationship object 494 | links: { 495 | self(resource, options, cb) { 496 | 497 | }, 498 | related(resource, options, cb) { 499 | 500 | } 501 | } 502 | } 503 | }, 504 | 505 | // a map of top-level links 506 | topLevelLinks: { 507 | self(options, cb) { 508 | 509 | } 510 | }, 511 | 512 | // a map of top-level meta members 513 | meta: { 514 | total(options, cb) { 515 | 516 | } 517 | }, 518 | 519 | // an array of string paths to pick from the resource. this option 520 | // overrides any specified blacklist and also includes relationships 521 | whitelist: [], 522 | } 523 | ``` 524 | --- 525 | 526 | #### serializeError(error, [meta], [defaultStatusCode]) => {object} document 527 | serializes any `error` into a JSON API v1.0 compliant error document. error can be anything, this method will attempt to intelligently construct a valid JSON API error object. the return document will contain a top level `meta` member with a `status` attribute that represents the status code with the greatest frequency. 528 | 529 | ###### Arguments 530 | | Param | Type | Description | 531 | | :---: | :---: | :--- | 532 | | `error` | `{*}` | the `error` data to serialize | 533 | | `[meta]` | `{Object}` | any top level meta information | 534 | | `[defaultStatusCode]` | `{Number|String}` | a default status code to apply to any error object(s) without a specified `status` | 535 | 536 | ###### Example 537 | ```javascript 538 | function(req, res) { 539 | async.waterfall([ 540 | // .. 541 | ], function(err, payload) { 542 | let status = 200; 543 | if (err) { 544 | payload = serializer.serializeError(err); 545 | status = payload.meta.status; 546 | } 547 | res.json(status, payload); 548 | }); 549 | } 550 | ``` 551 | 552 | 553 | ## Events 554 | The `serializer` inherits from node's `EventEmitter`. Below is a summary of the events exposed by this library. 555 | 556 | #### error 557 | The global error event. 558 | 559 | ###### Arguments 560 | | Param | Type | Description | 561 | | :---: | :---: | :--- | 562 | | `error` | `{Object}` | the error object | 563 | 564 | ###### Example 565 | ```javascript 566 | serializer.on('error', function(error) { 567 | bugsnag.notify(error); 568 | }); 569 | ``` 570 | 571 | 572 | ## To Do 573 | - [ ] implement `jsonapi` top-level member 574 | - [ ] implement `deserialize` method 575 | - [x] implement support for unpopulated relationships (an id, or array of ids) 576 | - [ ] implement *templates* 577 | - [ ] *ADD MORE TESTS!* 578 | 579 | 580 | ## Testing 581 | run tests 582 | ```bash 583 | npm test 584 | ``` 585 | 586 | 587 | ## Contributing 588 | 1. [Fork it](https://github.com/kutlerskaggs/json-api-ify/fork) 589 | 2. Create your feature branch (`git checkout -b my-new-feature`) 590 | 3. Commit your changes (`git commit -am 'Add some feature'`) 591 | 4. Push to the branch (`git push origin my-new-feature`) 592 | 5. Create new Pull Request 593 | 594 | 595 | ## License 596 | Copyright (c) 2016 Chris Ludden. 597 | Licensed under the [MIT license](LICENSE.md). 598 | -------------------------------------------------------------------------------- /test/basic.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'), 4 | chai = require('chai'), 5 | mongoose = require('mongoose'), 6 | Serializer = require('../index'), 7 | queryString = require('query-string'), 8 | _ = require('lodash'), 9 | ObjectID = require("bson-objectid"); 10 | 11 | let expect = chai.expect; 12 | 13 | describe('basic tests', function() { 14 | let dataset = [{ 15 | _id: 1, 16 | first: 'tim', 17 | last: 'tebow', 18 | email: 'ttebow@example.com', 19 | phone: { 20 | home: null, 21 | mobile: '+18001234567' 22 | }, 23 | groups: [{ 24 | name: 'admins', 25 | desc: 'site admins' 26 | },{ 27 | name: 'users', 28 | desc: 'all users' 29 | }] 30 | },{ 31 | _id: 2, 32 | first: 'kanye', 33 | last: 'west', 34 | email: 'kwest@example.com', 35 | phone: { 36 | home: null, 37 | mobile: '+18001234567' 38 | }, 39 | groups: [{ 40 | name: 'users', 41 | desc: 'all users' 42 | }] 43 | }]; 44 | 45 | let serializer = new Serializer({ 46 | baseUrl: 'https://www.example.com', 47 | includeSerializationTime: true, 48 | processResource(resource) { 49 | if (typeof resource.toObject === 'function') { 50 | resource = resource.toObject(); 51 | } else if (resource instanceof mongoose.Types.ObjectId) { 52 | resource = resource.toString(); 53 | } 54 | return resource; 55 | } 56 | }); 57 | 58 | serializer.on('error', function(err) { 59 | console.error(err); 60 | }); 61 | 62 | // configure serializer 63 | before(function(done) { 64 | async.auto({ 65 | user: function defineDefaultUserSchema(fn) { 66 | serializer.define('users', { 67 | id: '_id', 68 | blacklist: [ 69 | 'email', 70 | 'phone.home' 71 | ], 72 | links: { 73 | self(resource, options, cb) { 74 | let link = options.baseUrl + '/api/users/' + resource.id; 75 | cb(null, link); 76 | } 77 | }, 78 | meta: { 79 | test: function(resource, options, cb) { 80 | cb(null, 'test-' + resource.id); 81 | } 82 | }, 83 | relationships: { 84 | groups: { 85 | type: 'groups', 86 | include: true, 87 | links: { 88 | self(resource, options, cb) { 89 | let link = options.baseUrl + '/api/users/' + resource.id + '/groups'; 90 | cb(null, link); 91 | }, 92 | related(resource, options, cb) { 93 | let link = options.baseUrl + '/api/users/' + resource.id + '/relationships/groups'; 94 | cb(null, link); 95 | } 96 | } 97 | } 98 | }, 99 | topLevelLinks: { 100 | self(options, cb) { 101 | let link = options.baseUrl + '/api/users', 102 | query = _.get(options, 'request.query'); 103 | if (query) { 104 | link += '?' + queryString.stringify(query); 105 | } 106 | cb(null, link); 107 | }, 108 | next(options, cb) { 109 | let link = options.baseUrl + '/api/users', 110 | query = _.get(options, 'request.query') || {}, 111 | next = _.get(options, 'request.nextKey'); 112 | if (next) { 113 | query['page[cursor]'] = next; 114 | link += '?' + queryString.stringify(query); 115 | } else { 116 | link = undefined; 117 | } 118 | cb(null, link); 119 | } 120 | }, 121 | topLevelMeta: { 122 | 'api-version': 'v1.3.9', 123 | total: function(options, cb) { 124 | let total = _.get(options, 'request.total'); 125 | cb(null, total); 126 | } 127 | } 128 | }, fn); 129 | }, 130 | 131 | userPublic: function definePublicUserSchema(fn) { 132 | serializer.define('users', 'public', { 133 | whitelist: [ 134 | 'first', 135 | 'last' 136 | ] 137 | }, fn); 138 | }, 139 | 140 | group: function defineDefaultGroupSchema(fn) { 141 | serializer.define('groups', { 142 | id: 'name', 143 | links: { 144 | self(resource, options, cb) { 145 | let link = options.baseUrl + '/api/groups/' + resource.id; 146 | cb(null, link); 147 | } 148 | }, 149 | relationships: { 150 | users: { 151 | type: 'users', 152 | schema: 'public', 153 | include: true, 154 | links: { 155 | self(resource, options, cb) { 156 | let link = options.baseUrl + '/api/groups/' + resource.id + '/users'; 157 | cb(null, link); 158 | }, 159 | related(resource, options, cb) { 160 | let link = options.baseUrl + '/api/groups/' + resource.id + '/relationships/users'; 161 | cb(null, link); 162 | } 163 | } 164 | } 165 | }, 166 | topLevelLinks: { 167 | self(options, cb) { 168 | let link = options.baseUrl + '/api/groups'; 169 | cb(null, link); 170 | } 171 | } 172 | }, fn); 173 | }, 174 | }, done); 175 | }); 176 | 177 | context('with no options', function() { 178 | let err, payload; 179 | 180 | before(function(done) { 181 | serializer.serialize('users', dataset, function(e, p) { 182 | err = e; 183 | payload = p; 184 | done(e); 185 | }); 186 | }); 187 | 188 | it('should not error', function() { 189 | expect(err).to.not.exist; 190 | }); 191 | 192 | it('shoud return a valid payload', function() { 193 | expect(payload).to.be.an('object').and.contain.all.keys('links', 'data', 'included', 'meta'); 194 | }); 195 | 196 | it('should return the correct top level links', function() { 197 | expect(payload.links).to.have.property('self', 'https://www.example.com/api/users'); 198 | expect(payload.links).to.not.have.property('next'); 199 | }); 200 | 201 | it('should return the correct top level meta', function() { 202 | expect(payload.meta).to.have.property('serializationTime').that.is.a('string'); 203 | expect(payload.meta).to.not.have.property('total'); 204 | expect(payload.meta).to.have.property('api-version', 'v1.3.9'); 205 | }); 206 | 207 | it('should include 2 serialized users', function() { 208 | expect(payload.data).to.have.lengthOf(2); 209 | expect(_.map(payload.data, 'id')).to.eql([1,2]); 210 | expect(_.map(payload.data, 'type')).to.eql(['users', 'users']); 211 | payload.data.forEach(function(resource) { 212 | expect(resource).to.be.an('object') 213 | .and.contain.all.keys('type', 'id', 'attributes') 214 | .and.contain.any.keys('meta', 'links', 'relationships'); 215 | }); 216 | }); 217 | 218 | it('should include the appropriate attributes', function() { 219 | payload.data.forEach(function(resource) { 220 | expect(resource.attributes).to.contain.all.keys('first', 'last'); 221 | expect(resource.attributes).to.have.deep.property('phone.mobile'); 222 | expect(resource.attributes).to.not.have.property('email'); 223 | expect(resource.attributes).to.not.have.deep.property('phone.home'); 224 | }); 225 | }); 226 | 227 | it('each resource should include the correct relationships', function() { 228 | let tim = payload.data[0], 229 | kanye = payload.data[1]; 230 | expect(tim).to.have.property('relationships').that.is.an('object').with.property('groups'); 231 | expect(tim.relationships.groups).to.be.an('object').with.all.keys('links', 'data', 'meta'); 232 | expect(tim.relationships.groups.data).to.have.lengthOf(2); 233 | tim.relationships.groups.data.forEach(function(rel) { 234 | expect(rel).to.be.an('object').with.all.keys('id', 'type', 'links', 'meta'); 235 | }); 236 | expect(_.map(tim.relationships.groups.data, 'id')).to.eql(['admins', 'users']); 237 | 238 | expect(kanye).to.have.property('relationships').that.is.an('object').with.property('groups'); 239 | expect(kanye.relationships.groups).to.be.an('object').with.all.keys('links', 'data', 'meta'); 240 | expect(kanye.relationships.groups.data).to.have.lengthOf(1); 241 | kanye.relationships.groups.data.forEach(function(rel) { 242 | expect(rel).to.be.an('object').with.all.keys('id', 'type', 'links', 'meta'); 243 | }); 244 | expect(_.map(kanye.relationships.groups.data, 'id')).to.eql(['users']); 245 | }); 246 | 247 | it('each resource should include the correct meta', function() { 248 | payload.data.forEach(function(resource) { 249 | expect(resource).to.have.property('meta').that.is.an('object').with.keys('test'); 250 | expect(resource.meta.test).to.equal('test-' + resource.id); 251 | }); 252 | }); 253 | 254 | it('should include the correct related resources in the `included` attribute', function() { 255 | expect(payload.included).to.be.an('array').with.lengthOf(2); 256 | expect(_.map(payload.included, 'id')).to.contain('admins').and.contain('users'); 257 | }); 258 | }); 259 | 260 | context('with request options', function() { 261 | let err, payload; 262 | 263 | before(function(done) { 264 | serializer.serialize('users', dataset, { 265 | request: { 266 | query: { 267 | id: 'lte(20)' 268 | }, 269 | nextKey: 10, 270 | total: 1000 271 | } 272 | }, function(e, p) { 273 | err = e; 274 | payload = p; 275 | done(e); 276 | }); 277 | }); 278 | 279 | it('should not error', function() { 280 | expect(err).to.not.exist; 281 | }); 282 | 283 | it('should return a valid payload', function() { 284 | expect(payload).to.be.an('object').and.contain.all.keys('links', 'data', 'included', 'meta'); 285 | }); 286 | 287 | it('should return the correct top level links', function() { 288 | expect(payload.links).to.have.property('self').that.contains('https://www.example.com/api/users?'); 289 | expect(payload.links).to.have.property('next').that.contains('page').and.contains('cursor'); 290 | }); 291 | 292 | it('should return the correct top level meta', function() { 293 | expect(payload.meta).to.have.property('serializationTime').that.is.a('string'); 294 | expect(payload.meta).to.have.property('total', 1000); 295 | expect(payload.meta).to.have.property('api-version', 'v1.3.9'); 296 | }); 297 | 298 | it('should include 2 serialized users', function() { 299 | expect(payload.data).to.have.lengthOf(2); 300 | expect(_.map(payload.data, 'id')).to.eql([1,2]); 301 | expect(_.map(payload.data, 'type')).to.eql(['users', 'users']); 302 | payload.data.forEach(function(resource) { 303 | expect(resource).to.be.an('object') 304 | .and.contain.all.keys('type', 'id', 'attributes') 305 | .and.contain.any.keys('meta', 'links', 'relationships'); 306 | }); 307 | }); 308 | 309 | it('should include the appropriate attributes', function() { 310 | payload.data.forEach(function(resource) { 311 | expect(resource.attributes).to.contain.all.keys('first', 'last'); 312 | expect(resource.attributes).to.have.deep.property('phone.mobile'); 313 | expect(resource.attributes).to.not.have.property('email'); 314 | expect(resource.attributes).to.not.have.deep.property('phone.home'); 315 | }); 316 | }); 317 | 318 | it('each resource should include the correct relationships', function() { 319 | let tim = payload.data[0], 320 | kanye = payload.data[1]; 321 | expect(tim).to.have.property('relationships').that.is.an('object').with.property('groups'); 322 | expect(tim.relationships.groups).to.be.an('object').with.all.keys('links', 'data', 'meta'); 323 | expect(tim.relationships.groups.data).to.have.lengthOf(2); 324 | tim.relationships.groups.data.forEach(function(rel) { 325 | expect(rel).to.be.an('object').with.all.keys('id', 'type', 'links', 'meta'); 326 | }); 327 | expect(_.map(tim.relationships.groups.data, 'id')).to.eql(['admins', 'users']); 328 | 329 | expect(kanye).to.have.property('relationships').that.is.an('object').with.property('groups'); 330 | expect(kanye.relationships.groups).to.be.an('object').with.all.keys('links', 'data', 'meta'); 331 | expect(kanye.relationships.groups.data).to.have.lengthOf(1); 332 | kanye.relationships.groups.data.forEach(function(rel) { 333 | expect(rel).to.be.an('object').with.all.keys('id', 'type', 'links', 'meta'); 334 | }); 335 | expect(_.map(kanye.relationships.groups.data, 'id')).to.eql(['users']); 336 | }); 337 | 338 | it('each resource should include the correct meta', function() { 339 | payload.data.forEach(function(resource) { 340 | expect(resource).to.have.property('meta').that.is.an('object').with.keys('test'); 341 | expect(resource.meta.test).to.equal('test-' + resource.id); 342 | }); 343 | }); 344 | 345 | it('should include the correct related resources in the `included` attribute', function() { 346 | expect(payload.included).to.be.an('array').with.lengthOf(2); 347 | expect(_.map(payload.included, 'id')).to.contain('admins').and.contain('users'); 348 | }); 349 | }); 350 | 351 | context('with non default schema', function() { 352 | let err, payload; 353 | 354 | before(function(done) { 355 | serializer.serialize('users', 'public', dataset, { 356 | request: { 357 | query: { 358 | id: 'lte(20)' 359 | }, 360 | nextKey: 10, 361 | total: 1000 362 | } 363 | }, function(e, p) { 364 | err = e; 365 | payload = p; 366 | done(e); 367 | }); 368 | }); 369 | 370 | it('should not error', function() { 371 | expect(err).to.not.exist; 372 | }); 373 | 374 | it('shoud return a valid payload', function() { 375 | expect(payload).to.be.an('object').and.contain.all.keys('links', 'data', 'included', 'meta'); 376 | }); 377 | 378 | it('should return the correct top level links', function() { 379 | expect(payload.links).to.have.property('self').that.contains('https://www.example.com/api/users?'); 380 | expect(payload.links).to.have.property('next').that.contains('page').and.contains('cursor'); 381 | }); 382 | 383 | it('should return the correct top level meta', function() { 384 | expect(payload.meta).to.have.property('serializationTime').that.is.a('string'); 385 | expect(payload.meta).to.have.property('total', 1000); 386 | expect(payload.meta).to.have.property('api-version', 'v1.3.9'); 387 | }); 388 | 389 | it('should include 2 serialized users', function() { 390 | expect(payload.data).to.have.lengthOf(2); 391 | expect(_.map(payload.data, 'id')).to.eql([1,2]); 392 | expect(_.map(payload.data, 'type')).to.eql(['users', 'users']); 393 | payload.data.forEach(function(resource) { 394 | expect(resource).to.be.an('object') 395 | .and.contain.all.keys('type', 'id', 'attributes') 396 | .and.contain.any.keys('meta', 'links', 'relationships'); 397 | }); 398 | }); 399 | 400 | it('should blacklist the appropriate attributes', function() { 401 | payload.data.forEach(function(resource) { 402 | expect(resource.attributes).to.have.keys('first', 'last'); 403 | expect(resource.attributes).to.not.have.deep.property('phone.mobile'); 404 | expect(resource.attributes).to.not.have.property('email'); 405 | expect(resource.attributes).to.not.have.deep.property('phone.home'); 406 | }); 407 | }); 408 | 409 | it('each resource should include the correct relationships', function() { 410 | payload.data.forEach(function(resource) { 411 | expect(resource.relationships).to.eql({}); 412 | }); 413 | }); 414 | 415 | it('each resource should include the correct meta', function() { 416 | payload.data.forEach(function(resource) { 417 | expect(resource).to.have.property('meta').that.is.an('object').with.keys('test'); 418 | expect(resource.meta.test).to.equal('test-' + resource.id); 419 | }); 420 | }); 421 | 422 | it('should include the correct related resources in the `included` attribute', function() { 423 | expect(payload.included).to.be.an('array').with.lengthOf(0); 424 | }); 425 | }); 426 | 427 | context('single document', function() { 428 | let dataset2 = { 429 | name: 'users', 430 | desc: 'site admins', 431 | users: [{ 432 | _id: 1, 433 | first: 'tim', 434 | last: 'tebow', 435 | email: 'ttebow@example.com', 436 | phone: { 437 | home: null, 438 | mobile: '+18001234567' 439 | } 440 | },{ 441 | _id: 2, 442 | first: 'kanye', 443 | last: 'west', 444 | email: 'kwest@example.com', 445 | phone: { 446 | home: null, 447 | mobile: '+18001234567' 448 | }, 449 | }] 450 | }; 451 | 452 | let error, payload; 453 | before(function(done) { 454 | serializer.serialize('groups', dataset2, function(e, p) { 455 | error = e; 456 | payload = p; 457 | done(e); 458 | }); 459 | }); 460 | 461 | it('should not throw an error', function() { 462 | expect(error).to.not.exist; 463 | }); 464 | 465 | it('shoud return a valid payload', function() { 466 | expect(payload).to.be.an('object').and.contain.all.keys('links', 'data', 'included', 'meta'); 467 | }); 468 | 469 | it('should return the correct top level links', function() { 470 | expect(payload.links).to.have.property('self', 'https://www.example.com/api/groups'); 471 | }); 472 | 473 | it('should return the correct top level meta', function() { 474 | expect(payload.meta).to.have.property('serializationTime').that.is.a('string'); 475 | }); 476 | 477 | it('should include 1 serialized group', function() { 478 | expect(payload.data).to.be.an('object') 479 | .with.all.keys('id', 'type', 'attributes', 'relationships', 'meta', 'links'); 480 | }); 481 | 482 | it('should include the appropriate attributes', function() { 483 | expect(payload.data.attributes).to.have.all.keys('desc'); 484 | }); 485 | 486 | it('each resource should include the correct relationships', function() { 487 | expect(payload.data.relationships).to.have.all.keys('users'); 488 | expect(payload.data.relationships.users).to.have.all.keys('links', 'data', 'meta'); 489 | expect(payload.data.relationships.users.data).to.be.an('array') 490 | .with.lengthOf(2); 491 | expect(_.map(payload.data.relationships.users.data, 'id')).to.eql([1, 2]); 492 | }); 493 | 494 | it('each resource should include the correct meta', function() { 495 | expect(payload.data.meta).to.eql({}); 496 | }); 497 | 498 | it('should include the correct related resources in the `included` attribute', function() { 499 | expect(payload.included).to.be.an('array').with.lengthOf(2); 500 | }); 501 | }); 502 | 503 | context('unpopulated relationships with exotic ids', function() { 504 | let error, payload; 505 | before(function(done) { 506 | var mongoose = require('mongoose'), 507 | userSchema = new mongoose.Schema({ 508 | first: String, 509 | last: String, 510 | group: { 511 | type: mongoose.Schema.Types.ObjectId, 512 | ref: 'group' 513 | }, 514 | groups: [{ 515 | type: mongoose.Schema.Types.ObjectId, 516 | ref: 'group' 517 | }] 518 | }), 519 | User = mongoose.model('user', userSchema), 520 | groupSchema = new mongoose.Schema({ 521 | name: String 522 | }), 523 | Group = mongoose.model('group', groupSchema); 524 | 525 | let user = new User({ 526 | first: 'tim', 527 | last: 'tebow', 528 | group: new ObjectID('56cd74546033f8d420bc1c11'), 529 | groups: [new ObjectID('56cd74546033f8d420bc1c12'), new ObjectID('56cd74546033f8d420bc1c13')] 530 | }); 531 | 532 | serializer.serialize('users', user, { 533 | id: '_id', 534 | relationships: { 535 | group : { 536 | type: 'groups', 537 | }, 538 | groups : { 539 | type: 'groups', 540 | } 541 | } 542 | }, function(e, p) { 543 | error = e; 544 | payload = p; 545 | done(e); 546 | }); 547 | }); 548 | 549 | it('should not throw an error', function() { 550 | expect(error).to.not.exist; 551 | }); 552 | 553 | it('should return the correct relationships data ids', function() { 554 | expect(payload.data.relationships.group.data.id).to.eql('56cd74546033f8d420bc1c11'); 555 | expect(_.map(payload.data.relationships.groups.data, 'id')).to.eql(['56cd74546033f8d420bc1c12', '56cd74546033f8d420bc1c13']); 556 | }); 557 | }); 558 | }); 559 | --------------------------------------------------------------------------------