├── .editorconfig ├── .gitignore ├── .jsinspectrc ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── commitlint.config.js ├── lib ├── create.js ├── delete.js ├── deserialize.js ├── deserializer.js ├── errors.js ├── headers.js ├── index.js ├── patch.js ├── querystring.js ├── relationships.js ├── removeRemoteMethods.js ├── serialize.js ├── serializer.js ├── update.js ├── utilities │ └── relationship-utils.js └── utils.js ├── package.json └── test ├── belongsTo.test.js ├── belongsToPolymorphic.test.js ├── belongsToThroughHasMany.test.js ├── config.test.js ├── create.test.js ├── delete.test.js ├── errors.test.js ├── exclude.test.js ├── find.test.js ├── foreign-keys.test.js ├── hasMany.test.js ├── hasManyPolymorphic.test.js ├── hasManyRelationships.test.js ├── hasManyThroughUpsert.test.js ├── hasManyThroughUpsertStringIds.test.js ├── hasOne.test.js ├── include.test.js ├── override-attributes.test.js ├── override-deserializer.test.js ├── override-serializer.test.js ├── referencesMany.test.js ├── reflexiveRelationship.test.js ├── relationship-utils.test.js ├── relationships.test.js ├── remoteMethods.test.js ├── rule-hasmany-actions.test.js ├── scopeInclude.test.js ├── throughModel.test.js ├── update.test.js └── util └── query.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.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 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | .idea 29 | -------------------------------------------------------------------------------- /.jsinspectrc: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 30, 3 | "identifiers": true, 4 | "ignore": "test.js" 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | *.md 4 | **/*.md 5 | .DS_STORE 6 | .idea 7 | .editorconfig 8 | .travis.yml 9 | .jsinspectrc 10 | .eslintrc 11 | .eslintignore 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '8' 10 | - '6' 11 | - '4' 12 | before_script: 13 | - npm prune 14 | - 'npm i -g jsinspect' 15 | - 'npm i -g eslint' 16 | - 'npm i -g babel-eslint' 17 | - 'npm i -g istanbul' 18 | after_success: 19 | - 'curl -Lo travis_after_all.py https://git.io/travis_after_all' 20 | - python travis_after_all.py 21 | - export $(cat .to_export_back) &> /dev/null 22 | - npm run travis-deploy-once "npm run semantic-release" 23 | branches: 24 | except: 25 | - /^v\d+\.\d+\.\d+$/ 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Richard Walker 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | extends: ['@commitlint/config-conventional'], 5 | 6 | // Override rules. See http://marionebl.github.io/commitlint 7 | rules: { 8 | // Disable language rule 9 | lang: [0, 'always', 'eng'] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/create.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var url = require('url') 4 | var utils = require('./utils') 5 | var statusCodes = require('http-status-codes') 6 | 7 | module.exports = function (app, options) { 8 | // get remote methods. 9 | // set strong-remoting for more information 10 | // https://github.com/strongloop/strong-remoting 11 | var remotes = app.remotes() 12 | 13 | // register after remote method hook on all methods 14 | remotes.after('**', function (ctx, next) { 15 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 16 | return next() 17 | } 18 | 19 | // in this case we are only interested in handling create operations. 20 | if (ctx.method.name === 'create') { 21 | // JSON API specifies that created resources should have the 22 | // http status code of 201 23 | ctx.res.statusCode = statusCodes.CREATED 24 | 25 | // build the location url for the created resource. 26 | var location = url.format({ 27 | protocol: ctx.req.protocol, 28 | host: ctx.req.get('host'), 29 | pathname: ctx.req.baseUrl + '/' + ctx.result.data.id 30 | }) 31 | 32 | // we don't need the links property so just delete it. 33 | delete ctx.result.links 34 | 35 | // JSON API specifies that when creating a resource, there should be a 36 | // location header set with the url of the created resource as the value 37 | ctx.res.append('Location', location) 38 | } 39 | next() 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /lib/delete.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var utils = require('./utils') 4 | var statusCodes = require('http-status-codes') 5 | 6 | module.exports = function (app, options) { 7 | // get remote methods. 8 | // set strong-remoting for more information 9 | // https://github.com/strongloop/strong-remoting 10 | var remotes = app.remotes() 11 | 12 | // register after remote method hook on all methods 13 | remotes.after('**', function (ctx, next) { 14 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 15 | return next() 16 | } 17 | 18 | // in this case we are only interested in handling create operations. 19 | if (ctx.method.name === 'deleteById') { 20 | // JSON API specifies that successful 21 | // deletes with no content returned should be 204 22 | if (ctx.res.statusCode === statusCodes.OK) { 23 | ctx.res.status(statusCodes.NO_CONTENT) 24 | } 25 | } 26 | next() 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /lib/deserialize.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global require,module */ 4 | var deserializer = require('./deserializer') 5 | var RelUtils = require('./utilities/relationship-utils') 6 | var utils = require('./utils') 7 | 8 | module.exports = function (app, options) { 9 | /** 10 | * Register a handler to run before all remote methods so that we can 11 | * transform JSON API structured JSON payload into something loopback 12 | * can work with. 13 | */ 14 | app.remotes().before('**', function (ctx, next) { 15 | var data, serverRelations, errors 16 | 17 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 18 | return next() 19 | } 20 | 21 | var regexs = [/^create$/, /^updateAttributes$/, /^patchAttributes$/] 22 | 23 | var matches = regexs.filter(function (regex) { 24 | return regex.test(ctx.method.name) 25 | }) 26 | 27 | /** 28 | * Handle include relationship requests (aka sideloading) 29 | */ 30 | if (RelUtils.isRequestingIncludes(ctx)) { 31 | ctx.res.set({ 'Content-Type': 'application/vnd.api+json' }) 32 | 33 | ctx.req.isSideloadingRelationships = true 34 | 35 | if (RelUtils.isLoopbackInclude(ctx)) { 36 | return next() 37 | } 38 | 39 | if (!RelUtils.shouldIncludeRelationships(ctx.req.method)) { 40 | return next(RelUtils.getInvalidIncludesError()) 41 | } 42 | 43 | var include = RelUtils.getIncludesArray(ctx.req.query) 44 | include = include.length === 1 ? include[0] : include 45 | 46 | ctx.args = ctx.args || {} 47 | ctx.args.filter = ctx.args.filter || {} 48 | ctx.args.filter.include = include 49 | } 50 | 51 | if (utils.shouldApplyJsonApi(ctx, options) || matches.length > 0) { 52 | options.debug('Will deserialize incoming payload') 53 | options.debug('Set Content-Type to `application/vnd.api+json`') 54 | 55 | // set the JSON API Content-Type response header 56 | ctx.res.set({ 'Content-Type': 'application/vnd.api+json' }) 57 | 58 | options.model = utils.getModelFromContext(ctx, app) 59 | options.method = ctx.method.name 60 | 61 | /** 62 | * Check the incoming payload data to ensure it is valid according to the 63 | * JSON API spec. 64 | */ 65 | data = ctx.args.data 66 | 67 | if (!data) { 68 | return next() 69 | } 70 | 71 | options.debug('Validating payload data to be deserialized') 72 | errors = validateRequest(data, ctx.req.method) 73 | if (errors) { 74 | options.debug('Error:', errors) 75 | return next(new Error(errors)) 76 | } 77 | 78 | options.data = data 79 | 80 | serverRelations = utils.getRelationsFromContext(ctx, app) 81 | 82 | options.relationships = serverRelations 83 | 84 | options.debug('======================') 85 | options.debug('Deserializer input ') 86 | options.debug('======================') 87 | options.debug('DESERIALIZER OPTIONS |:', JSON.stringify(options)) 88 | 89 | // transform the payload 90 | deserializer(options, function (err, deserializerOptions) { 91 | if (err) return next(err) 92 | 93 | options.data = deserializerOptions.data 94 | ctx.args.data = deserializerOptions.result 95 | 96 | options.debug('======================') 97 | options.debug('Deserializer output ') 98 | options.debug('======================') 99 | options.debug( 100 | 'DESERIALIZED RESULT |:', 101 | JSON.stringify(deserializerOptions.result) 102 | ) 103 | next() 104 | }) 105 | } else { 106 | next() 107 | } 108 | }) 109 | } 110 | 111 | /** 112 | * Check for errors in the request. If it has an error it will return a string, otherwise false. 113 | * @private 114 | * @memberOf {Deserialize} 115 | * @param {Object} data 116 | * @param {String} requestMethod 117 | * @return {String|false} 118 | */ 119 | function validateRequest (data, requestMethod) { 120 | if (!data.data) { 121 | return 'JSON API resource object must contain `data` property' 122 | } 123 | 124 | if (!data.data.type) { 125 | return 'JSON API resource object must contain `data.type` property' 126 | } 127 | 128 | if (['PATCH', 'PUT'].indexOf(requestMethod) > -1 && !data.data.id) { 129 | return 'JSON API resource object must contain `data.id` property' 130 | } 131 | 132 | return false 133 | } 134 | -------------------------------------------------------------------------------- /lib/deserializer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var _ = require('lodash') 4 | 5 | function defaultBeforeDeserialize (options, cb) { 6 | cb(null, options) 7 | } 8 | 9 | function defaultDeserialize (options, cb) { 10 | options.result = options.data.data.attributes || {} 11 | cb(null, options) 12 | } 13 | 14 | function defaultAfterDeserialize (options, cb) { 15 | cb(null, options) 16 | } 17 | 18 | /** 19 | * Deserializes the requests data. 20 | * @public 21 | * @type {Function} 22 | * @param {Object} data The request data 23 | * @param {Object} serverRelations 24 | * @return {Object} 25 | */ 26 | module.exports = function deserializer (options, cb) { 27 | var model = options.model 28 | 29 | var beforeDeserialize = typeof model.beforeJsonApiDeserialize === 'function' 30 | ? model.beforeJsonApiDeserialize 31 | : defaultBeforeDeserialize 32 | 33 | var deserialize = typeof model.jsonApiDeserialize === 'function' 34 | ? model.jsonApiDeserialize 35 | : defaultDeserialize 36 | 37 | var afterDeserialize = typeof model.afterJsonApiDeserialize === 'function' 38 | ? model.afterJsonApiDeserialize 39 | : defaultAfterDeserialize 40 | 41 | var deserializeOptions = _.cloneDeep(options) 42 | 43 | beforeDeserialize(deserializeOptions, function (err, deserializeOptions) { 44 | if (err) return cb(err) 45 | deserialize(deserializeOptions, function (err, deserializeOptions) { 46 | if (err) return cb(err) 47 | afterDeserialize(deserializeOptions, function (err, deserializeOptions) { 48 | if (err) return cb(err) 49 | 50 | belongsToRelationships(deserializeOptions) 51 | return cb(null, deserializeOptions) 52 | }) 53 | }) 54 | }) 55 | } 56 | 57 | function belongsToRelationships (options) { 58 | var data = options.data 59 | var model = options.model 60 | 61 | if (!data || !data.data || !model || !data.data.relationships) { 62 | return 63 | } 64 | 65 | _.each(data.data.relationships, function (relationship, name) { 66 | var serverRelation = model.relations[name] 67 | if (!serverRelation) return 68 | var type = serverRelation.type 69 | 70 | // only handle belongsTo 71 | if (type !== 'belongsTo') return 72 | 73 | var fkName = serverRelation.keyFrom 74 | var modelTo = serverRelation.modelFrom 75 | 76 | if (!modelTo) return false 77 | 78 | if (!relationship.data) { 79 | options.result[fkName] = null 80 | } else { 81 | options.result[fkName] = relationship.data.id 82 | } 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var debug 4 | var errorStackInResponse 5 | var statusCodes = require('http-status-codes') 6 | var _ = require('lodash') 7 | 8 | module.exports = function (app, options) { 9 | debug = options.debug 10 | errorStackInResponse = options.errorStackInResponse 11 | 12 | if (options.handleErrors !== false) { 13 | debug( 14 | 'Register custom error handler to transform errors to meet jsonapi spec' 15 | ) 16 | var remotes = app.remotes() 17 | remotes.options.rest = remotes.options.rest || {} 18 | remotes.options.rest.handleErrors = false 19 | app.middleware('final', JSONAPIErrorHandler) 20 | } 21 | } 22 | 23 | /** 24 | * Our JSON API Error handler. 25 | * @public 26 | * @memberOf {Errors} 27 | * @param {Object} err The error object 28 | * @param {Object} req The request object 29 | * @param {Object} res The response object 30 | * @param {Function} next 31 | * @return {undefined} 32 | */ 33 | function JSONAPIErrorHandler (err, req, res, next) { 34 | debug('Handling error(s) using custom jsonapi error handler') 35 | debug('Set Content-Type header to `application/vnd.api+json`') 36 | res.set('Content-Type', 'application/vnd.api+json') 37 | 38 | var errors = [] 39 | var statusCode = err.statusCode || 40 | err.status || 41 | statusCodes.INTERNAL_SERVER_ERROR 42 | debug('Raw error object:', err) 43 | 44 | if (err.details && err.details.messages) { 45 | debug('Handling error as a validation error.') 46 | 47 | // This block is for handling validation errors. 48 | // Build up an array of validation errors. 49 | errors = Object.keys(err.details.messages).map(function (key) { 50 | return buildErrorResponse( 51 | statusCode, 52 | err.details.messages[key][0], 53 | err.details.codes[key][0], 54 | err.name, 55 | { pointer: 'data/attributes/' + key } 56 | ) 57 | }) 58 | } else if (err.message) { 59 | // convert specific errors below to validation errors. 60 | // These errors are from checks on the incoming payload to ensure it is 61 | // JSON API compliant. If we switch to being able to use the Accept header 62 | // to decide whether to handle the request as JSON API, these errors would 63 | // need to only be applied when the Accept header is `application/vnd.api+json` 64 | var additionalValidationErrors = [ 65 | 'JSON API resource object must contain `data` property', 66 | 'JSON API resource object must contain `data.type` property', 67 | 'JSON API resource object must contain `data.id` property' 68 | ] 69 | 70 | if (additionalValidationErrors.indexOf(err.message) !== -1) { 71 | debug('Recasting error as a validation error.') 72 | statusCode = statusCodes.UNPROCESSABLE_ENTITY 73 | err.code = 'presence' 74 | err.name = 'ValidationError' 75 | } 76 | 77 | debug('Handling invalid relationship specified in url') 78 | if (/Relation (.*) is not defined for (.*) model/.test(err.message)) { 79 | statusCode = statusCodes.BAD_REQUEST 80 | err.message = 'Bad Request' 81 | err.code = 'INVALID_INCLUDE_TARGET' 82 | err.name = 'BadRequest' 83 | } 84 | 85 | var errorSource = err.source && typeof err.source === 'object' 86 | ? err.source 87 | : {} 88 | if (errorStackInResponse) { 89 | // We do not want to mutate err.source, so we clone it first 90 | errorSource = _.clone(errorSource) 91 | errorSource.stack = err.stack 92 | } 93 | 94 | errors.push( 95 | buildErrorResponse( 96 | statusCode, 97 | err.message, 98 | err.code, 99 | err.name, 100 | errorSource, 101 | err.meta 102 | ) 103 | ) 104 | } else { 105 | debug( 106 | 'Unable to determin error type. Treating error as a general 500 server error.' 107 | ) 108 | // catch all server 500 error if we were unable to understand the error. 109 | errors.push( 110 | buildErrorResponse( 111 | statusCodes.INTERNAL_SERVER_ERROR, 112 | 'Internal Server error', 113 | 'GENERAL_SERVER_ERROR' 114 | ) 115 | ) 116 | } 117 | 118 | // send the errors and close out the response. 119 | debug('Sending error response') 120 | debug('Response Code:', statusCode) 121 | debug('Response Object:', { errors: errors }) 122 | res.status(statusCode).send({ errors: errors }).end() 123 | } 124 | 125 | /** 126 | * Builds an error object for sending to the user. 127 | * @private 128 | * @memberOf {Errors} 129 | * @param {Number} httpStatusCode specific http status code 130 | * @param {String} errorDetail error message for the user, human readable 131 | * @param {String} errorCode internal system error code 132 | * @param {String} errorName error title for the user, human readable 133 | * @param {String} errorSource Some information about the source of the issue 134 | * @param {String} errorMeta Some custom meta information to give to the error response 135 | * @return {Object} 136 | */ 137 | function buildErrorResponse ( 138 | httpStatusCode, 139 | errorDetail, 140 | errorCode, 141 | errorName, 142 | errorSource, 143 | errorMeta 144 | ) { 145 | var out = { 146 | status: httpStatusCode || statusCodes.INTERNAL_SERVER_ERROR, 147 | source: errorSource || {}, 148 | title: errorName || '', 149 | code: errorCode || '', 150 | detail: errorDetail || '' 151 | } 152 | 153 | if (errorMeta && typeof errorMeta === 'object') { 154 | out.meta = errorMeta 155 | } 156 | 157 | return out 158 | } 159 | -------------------------------------------------------------------------------- /lib/headers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var is = require('type-is') 4 | var _ = require('lodash') 5 | var utils = require('./utils') 6 | 7 | module.exports = function (app, options) { 8 | var remotes = app.remotes() 9 | 10 | remotes.before('**', function (ctx, next) { 11 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 12 | return next() 13 | } 14 | 15 | options.debug('Add support for `application/vnd.api+json` accept headers') 16 | // We must force `application/json` until we can override it through strong remoting 17 | if (ctx.req.accepts('application/vnd.api+json')) { 18 | ctx.req.headers.accept = 'application/json' 19 | } 20 | 21 | next() 22 | }) 23 | 24 | options.debug('Add `application/vnd.api+json` to supported types') 25 | var rest = remotes.options.rest = remotes.options.rest || {} 26 | rest.supportedTypes = rest.supportedTypes || [] 27 | rest.supportedTypes = _.union(rest.supportedTypes, [ 28 | 'json', 29 | 'application/javascript', 30 | 'text/javascript', 31 | 'application/vnd.api+json' 32 | ]) 33 | 34 | // extend rest body parser to also parse application/vnd.api+json 35 | options.debug('Extend body parser to support `application/vnd.api+json`') 36 | remotes.options.json = remotes.options.json || {} 37 | remotes.options.json = _.extend(remotes.options.json, { 38 | strict: false, 39 | type: function (req) { 40 | // if Content-Type is any of the following, then parse otherwise don't 41 | return !!is(req, [ 42 | 'json', 43 | 'application/json', 44 | 'application/vnd.api+json' 45 | ]) 46 | } 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var _ = require('lodash') 4 | var headers = require('./headers') 5 | var patch = require('./patch') 6 | var serialize = require('./serialize') 7 | var deserialize = require('./deserialize') 8 | var removeRemoteMethods = require('./removeRemoteMethods') 9 | var create = require('./create') 10 | var update = require('./update') 11 | var del = require('./delete') 12 | var errors = require('./errors') 13 | var relationships = require('./relationships') 14 | var querystring = require('./querystring') 15 | var debug = require('debug')('loopback-component-jsonapi') 16 | 17 | module.exports = function (app, options) { 18 | var defaultOptions = { 19 | restApiRoot: '/api', 20 | enable: true, 21 | foreignKeys: false 22 | } 23 | options = options || {} 24 | options = _.defaults(options, defaultOptions) 25 | 26 | if (!options.enable) { 27 | debug('Disabled') 28 | return 29 | } 30 | debug('Started') 31 | options.debug = debug 32 | headers(app, options) 33 | removeRemoteMethods(app, options) 34 | patch(app, options) 35 | serialize(app, options) 36 | deserialize(app, options) 37 | relationships(app, options) 38 | create(app, options) 39 | update(app, options) 40 | errors(app, options) 41 | del(app, options) 42 | querystring(app, options) 43 | } 44 | -------------------------------------------------------------------------------- /lib/patch.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var statusCodes = require('http-status-codes') 4 | 5 | module.exports = function (app, options) { 6 | // iterate over all loopback models. This gives us a constructor function 7 | // and allows us to overwrite the relationship methods: 8 | // - belongsToRemoting 9 | // - hasOneRemoting 10 | // - hasManyRemoting 11 | // - scopeRemoting 12 | // - etc 13 | // 14 | // Note: This does not seem ideal. 15 | // We are copying a tonne of code out of loopbacks Model class 16 | // and then modifying little bits of it below. 17 | // see: https://github.com/strongloop/loopback/blob/master/lib/model.js#L462-L661 18 | // Most likely this will be fragile and 19 | // require us to keep up to date with any chances introduced in loopback. 20 | // 21 | // It would be good to have a discussion with the strongloop guys and see 22 | // if there is a better way this can be done. 23 | options.debug( 24 | 'Replace relationship remoting functions with custom implementations' 25 | ) 26 | app.models().forEach(function (ctor) { 27 | ctor.belongsToRemoting = belongsToRemoting 28 | ctor.hasOneRemoting = hasOneRemoting 29 | ctor.hasManyRemoting = hasManyRemoting 30 | ctor.scopeRemoting = scopeRemoting 31 | }) 32 | options.debug( 33 | '`belongsToRemoting`, `hasOneRemoting`, `hasManyRemoting`, `scopeRemoting` replaced' 34 | ) 35 | 36 | // iterate through all remote methods and swap PUTs to PATCHs 37 | // as PUT is not supported by JSON API. 38 | options.debug('Replace PUT http verb with PATCH to support jsonapi spec') 39 | app.remotes().methods().forEach(fixHttpMethod) 40 | } 41 | 42 | /** 43 | * Copied from loopbacks Model class. Changes `PUT` request to `PATCH` 44 | * @private 45 | * @memberOf {Patch} 46 | * @param {Function} fn 47 | * @param {String} name 48 | * @return {undefined} 49 | */ 50 | function fixHttpMethod (fn, name) { 51 | if (fn.http && fn.http.verb && fn.http.verb.toLowerCase() === 'put') { 52 | fn.http.verb = 'patch' 53 | } 54 | } 55 | 56 | /** 57 | * Copied in its entirity from loopbacks Model class. 58 | * it was necessary to do so as this function is used 59 | * in the other code below 60 | */ 61 | function convertNullToNotFoundError (toModelName, ctx, cb) { 62 | if (ctx.result !== null) return cb() 63 | 64 | var fk = ctx.getArgByName('fk') 65 | var msg = 'Unknown "' + toModelName + '" id "' + fk + '".' 66 | var error = new Error(msg) 67 | error.statusCode = error.status = statusCodes.NOT_FOUND 68 | error.code = 'MODEL_NOT_FOUND' 69 | cb(error) 70 | } 71 | 72 | function belongsToRemoting (relationName, relation, define) { 73 | var fn = this.prototype[relationName] 74 | var modelName = (relation.modelTo && relation.modelTo.modelName) || 75 | 'PersistedModel' 76 | var pathName = (relation.options.http && relation.options.http.path) || 77 | relationName 78 | 79 | define( 80 | '__get__' + relationName, 81 | { 82 | isStatic: false, 83 | accessType: 'READ', 84 | description: 'Fetches belongsTo relation ' + relationName + '.', 85 | http: { 86 | verb: 'get', 87 | path: '/' + pathName 88 | }, 89 | accepts: { 90 | arg: 'refresh', 91 | type: 'boolean', 92 | http: { 93 | source: 'query' 94 | } 95 | }, 96 | returns: { 97 | arg: relationName, 98 | type: modelName, 99 | root: true 100 | } 101 | }, 102 | fn 103 | ) 104 | 105 | var findBelongsToRelationshipsFunc = function (cb) { 106 | this['__get__' + pathName](cb) 107 | } 108 | 109 | define( 110 | '__findRelationships__' + relationName, 111 | { 112 | isStatic: false, 113 | accessType: 'READ', 114 | description: 'Find relations for ' + relationName + '.', 115 | http: { 116 | verb: 'get', 117 | path: '/relationships/' + pathName 118 | }, 119 | returns: { 120 | arg: 'result', 121 | type: modelName, 122 | root: true 123 | } 124 | }, 125 | findBelongsToRelationshipsFunc 126 | ) 127 | } 128 | 129 | /** 130 | * Defines has one remoting. 131 | * @public 132 | * @memberOf {Patch} 133 | * @param {String} relationName 134 | * @param {Object} relation 135 | * @param {Function} define 136 | * @return {undefined} 137 | */ 138 | function hasOneRemoting (relationName, relation, define) { 139 | var pathName = (relation.options.http && relation.options.http.path) || 140 | relationName 141 | var toModelName = relation.modelTo.modelName 142 | 143 | define('__get__' + relationName, { 144 | isStatic: false, 145 | accessType: 'READ', 146 | description: 'Fetches hasOne relation ' + relationName + '.', 147 | http: { 148 | verb: 'get', 149 | path: '/' + pathName 150 | }, 151 | accepts: { 152 | arg: 'refresh', 153 | type: 'boolean', 154 | http: { 155 | source: 'query' 156 | } 157 | }, 158 | returns: { 159 | arg: relationName, 160 | type: relation.modelTo.modelName, 161 | root: true 162 | } 163 | }) 164 | 165 | var findHasOneRelationshipsFunc = function (cb) { 166 | this['__get__' + pathName](cb) 167 | } 168 | 169 | define( 170 | '__findRelationships__' + relationName, 171 | { 172 | isStatic: false, 173 | accessType: 'READ', 174 | description: 'Find relations for ' + relationName + '.', 175 | http: { 176 | verb: 'get', 177 | path: '/relationships/' + pathName 178 | }, 179 | returns: { 180 | arg: 'result', 181 | type: toModelName, 182 | root: true 183 | } 184 | }, 185 | findHasOneRelationshipsFunc 186 | ) 187 | } 188 | 189 | /** 190 | * Defines has many remoting. 191 | * @public 192 | * @memberOf {Patch} 193 | * @param {String} relationName 194 | * @param {Object} relation 195 | * @param {Function} define 196 | * @return {undefined} 197 | */ 198 | function hasManyRemoting (relationName, relation, define) { 199 | var pathName = (relation.options.http && relation.options.http.path) || 200 | relationName 201 | var toModelName = relation.modelTo.modelName 202 | 203 | var findHasManyRelationshipsFunc = function (cb) { 204 | this['__get__' + pathName](cb) 205 | } 206 | 207 | define( 208 | '__findRelationships__' + relationName, 209 | { 210 | isStatic: false, 211 | accessType: 'READ', 212 | description: 'Find relations for ' + relationName + '.', 213 | http: { 214 | verb: 'get', 215 | path: '/relationships/' + pathName 216 | }, 217 | returns: { 218 | arg: 'result', 219 | type: toModelName, 220 | root: true 221 | }, 222 | rest: { 223 | after: convertNullToNotFoundError.bind(null, toModelName) 224 | } 225 | }, 226 | findHasManyRelationshipsFunc 227 | ) 228 | 229 | // var createRelationshipFunc = function (cb) { 230 | // TODO: implement this 231 | // this is where we need to implement 232 | // POST /:model/:id/relationships/:relatedModel 233 | // 234 | // this['__get__' + pathName](cb) 235 | // } 236 | // define('__createRelationships__' + relationName, { 237 | // isStatic: false, 238 | // accessType: 'READ', 239 | // description: 'Create relations for ' + relationName + '.', 240 | // http: { 241 | // verb: 'post', 242 | // path: '/relationships/' + pathName 243 | // }, 244 | // returns: { 245 | // arg: 'result', 246 | // type: toModelName, 247 | // root: true 248 | // }, 249 | // rest: { 250 | // after: convertNullToNotFoundError.bind(null, toModelName) 251 | // } 252 | // }, createRelationshipFunc) 253 | 254 | // var updateRelationshipsFunc = function (cb) { 255 | // TODO: implement this 256 | // this is where we need to implement 257 | // PATCH /:model/:id/relationships/:relatedModel 258 | // 259 | // this['__get__' + pathName](cb) 260 | // } 261 | // define('__updateRelationships__' + relationName, { 262 | // isStatic: false, 263 | // accessType: 'READ', 264 | // description: 'Update relations for ' + relationName + '.', 265 | // http: { 266 | // verb: 'patch', 267 | // path: '/relationships/' + pathName 268 | // }, 269 | // returns: { 270 | // arg: 'result', 271 | // type: toModelName, 272 | // root: true 273 | // }, 274 | // rest: { 275 | // after: convertNullToNotFoundError.bind(null, toModelName) 276 | // } 277 | // }, updateRelationshipsFunc) 278 | 279 | // var deleteRelationshipsFunc = function (cb) { 280 | // TODO: implement this 281 | // this is where we need to implement 282 | // DELETE /:model/:id/relationships/:relatedModel 283 | // 284 | // this['__get__' + pathName](cb) 285 | // } 286 | // define('__deleteRelationships__' + relationName, { 287 | // isStatic: false, 288 | // accessType: 'READ', 289 | // description: 'Delete relations for ' + relationName + '.', 290 | // http: { 291 | // verb: 'delete', 292 | // path: '/relationships/' + pathName 293 | // }, 294 | // returns: { 295 | // arg: 'result', 296 | // type: toModelName, 297 | // root: true 298 | // }, 299 | // rest: { 300 | // after: convertNullToNotFoundError.bind(null, toModelName) 301 | // } 302 | // }, deleteRelationshipsFunc) 303 | 304 | if (relation.modelThrough || relation.type === 'referencesMany') { 305 | var modelThrough = relation.modelThrough || relation.modelTo 306 | 307 | var accepts = [] 308 | if (relation.type === 'hasMany' && relation.modelThrough) { 309 | // Restrict: only hasManyThrough relation can have additional properties 310 | accepts.push({ 311 | arg: 'data', 312 | type: modelThrough.modelName, 313 | http: { 314 | source: 'body' 315 | } 316 | }) 317 | } 318 | } 319 | } 320 | 321 | /** 322 | * Defines our scope remoting 323 | * @public 324 | * @memberOf {Patch} 325 | * @param {String} scopeName 326 | * @param {Object} scope 327 | * @param {Function} define 328 | * @return {undefined} 329 | */ 330 | function scopeRemoting (scopeName, scope, define) { 331 | var pathName = (scope.options && 332 | scope.options.http && 333 | scope.options.http.path) || 334 | scopeName 335 | var isStatic = scope.isStatic 336 | var toModelName = scope.modelTo.modelName 337 | 338 | // https://github.com/strongloop/loopback/issues/811 339 | // Check if the scope is for a hasMany relation 340 | var relation = this.relations[scopeName] 341 | 342 | if (relation && relation.modelTo) { 343 | // For a relation with through model, the toModelName should be the one 344 | // from the target model 345 | toModelName = relation.modelTo.modelName 346 | } 347 | 348 | define('__get__' + scopeName, { 349 | isStatic: isStatic, 350 | accessType: 'READ', 351 | description: 'Queries ' + scopeName + ' of ' + this.modelName + '.', 352 | http: { 353 | verb: 'get', 354 | path: '/' + pathName 355 | }, 356 | accepts: { 357 | arg: 'filter', 358 | type: 'object' 359 | }, 360 | returns: { 361 | arg: scopeName, 362 | type: [toModelName], 363 | root: true 364 | } 365 | }) 366 | } 367 | -------------------------------------------------------------------------------- /lib/querystring.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var utils = require('./utils') 4 | 5 | module.exports = function (app, options) { 6 | var remotes = app.remotes() 7 | remotes.before('**', function (ctx, next) { 8 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 9 | return next() 10 | } 11 | var query = ctx.req.query 12 | ctx.args.filter = ctx.args.filter || {} 13 | if (typeof query.page === 'object') { 14 | [ 15 | { from: 'offset', to: 'skip' }, 16 | { from: 'limit', to: 'limit' } 17 | ].forEach(function (p) { 18 | if (typeof query.page[p.from] === 'string') { 19 | ctx.args.filter[p.to] = query.page[p.from] 20 | } 21 | }) 22 | } 23 | return next() 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /lib/relationships.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var _ = require('lodash') 4 | var utils = require('./utils') 5 | const linkRelatedModels = require( 6 | './utilities/relationship-utils' 7 | ).linkRelatedModels 8 | 9 | module.exports = function (app, options) { 10 | // get remote methods. 11 | // set strong-remoting for more information 12 | // https://github.com/strongloop/strong-remoting 13 | var remotes = app.remotes() 14 | var id, data, model 15 | 16 | remotes.before('**', function (ctx, next) { 17 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 18 | return next() 19 | } 20 | 21 | var allowedMethodNames = ['updateAttributes', 'patchAttributes'] 22 | var methodName = ctx.method.name 23 | if (allowedMethodNames.indexOf(methodName) === -1) return next() 24 | 25 | id = ctx.req.params.id 26 | data = options.data 27 | model = utils.getModelFromContext(ctx, app) 28 | 29 | relationships(model, id, data).then(() => next()).catch(err => next(err)) 30 | }) 31 | 32 | // for create 33 | remotes.after('**', function (ctx, next) { 34 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 35 | return next() 36 | } 37 | 38 | if (ctx.method.name !== 'create') return next() 39 | 40 | if (ctx.result && ctx.result.data) { 41 | id = ctx.result.data.id 42 | data = options.data 43 | model = utils.getModelFromContext(ctx, app) 44 | relationships(model, id, data).then(() => next()).catch(err => next(err)) 45 | return 46 | } 47 | 48 | next() 49 | }) 50 | } 51 | 52 | function extractIdsFromResource (resource) { 53 | if (_.isArray(resource)) { 54 | return _.map(resource, 'id') 55 | } 56 | return _.get(resource, 'id', null) 57 | } 58 | 59 | function relationships (model, id, payload) { 60 | if (!id || !model) return 61 | const relationships = _.get(payload, 'data.relationships', {}) 62 | 63 | return Promise.all( 64 | Object.keys(relationships).map(relationName => { 65 | const relationship = relationships[relationName] 66 | const relationDefn = model.relations[relationName] 67 | if (!relationDefn) return 68 | 69 | const type = relationDefn.type 70 | const modelTo = relationDefn.modelTo 71 | 72 | // don't handle belongsTo in relationships function 73 | if (!modelTo || type === 'belongsTo') return 74 | 75 | const data = extractIdsFromResource(relationship.data) 76 | const from = { model, id } 77 | const to = { model: modelTo, data } 78 | return linkRelatedModels(relationName, from, to) 79 | }) 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /lib/removeRemoteMethods.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function (app, options) { 4 | var models = app.models 5 | 6 | // use loopbacks standard API to disable all methods that JSON API 7 | // does not support. 8 | 9 | if (options.hideIrrelevantMethods !== false) { 10 | options.debug( 11 | 'Disable methods not supported by `jsonapi`. (Set `options.hideIrrelevantMethods = false` to re-enable)' 12 | ) 13 | Object.keys(models).forEach(function (model) { 14 | [ 15 | 'upsert', 16 | 'exists', 17 | 'findOne', 18 | 'count', 19 | 'createChangeStream', 20 | 'updateAll' 21 | ].forEach(function (method) { 22 | if (models[model].disableRemoteMethodByName) { 23 | models[model].disableRemoteMethodByName(method) 24 | } else if (models[model].disableRemoteMethod) { 25 | models[model].disableRemoteMethod(method, true) 26 | } 27 | }) 28 | }) 29 | options.debug( 30 | '`upsert`, `exists`, `findOne`, `count`, `createChangeStream` and `updateAll` disabled for all models' 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/serialize.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global module,require */ 4 | var serializer = require('./serializer') 5 | var utils = require('./utils') 6 | var _ = require('lodash') 7 | var url = require('url') 8 | var regexs = [ 9 | /^find$/, 10 | /^create$/, 11 | /^deleteById$/, 12 | /^findById$/, 13 | /^__get__.*/, 14 | /^updateAttributes$/, 15 | /^patchAttributes$/, 16 | /^__findRelationships__.*/ 17 | ] 18 | 19 | module.exports = function (app, defaults) { 20 | defaults.debug('Register jsonapi serializer') 21 | app.remotes().after('**', function (ctx, next) { 22 | var data, 23 | type, 24 | path, 25 | options, 26 | modelNamePlural, 27 | modelPath, 28 | relatedModel, 29 | relatedModelPlural, 30 | relatedModelPath, 31 | relations, 32 | model, 33 | requestedIncludes 34 | 35 | if (utils.shouldNotApplyJsonApi(ctx, defaults)) { 36 | return next() 37 | } 38 | 39 | var matches = regexs.filter(function (regex) { 40 | return regex.test(ctx.method.name) 41 | }) 42 | 43 | if (!utils.shouldApplyJsonApi(ctx, defaults) && !matches.length) { 44 | return next() 45 | } 46 | 47 | // housekeeping, just skip verbs we definitely aren't 48 | // interested in handling. 49 | switch (ctx.req.method) { 50 | case 'DELETE': 51 | case 'PUT': 52 | case 'HEAD': 53 | return next() 54 | } 55 | 56 | defaults.debug('Response will be serialized') 57 | defaults.debug('Set response Content-Type to `application/vnd.api+json`') 58 | ctx.res.set({ 59 | 'Content-Type': 'application/vnd.api+json' 60 | }) 61 | 62 | data = utils.clone(ctx.result) 63 | model = utils.getModelFromContext(ctx, app) 64 | modelNamePlural = utils.pluralForModel(model) 65 | modelPath = utils.httpPathForModel(model) 66 | type = modelNamePlural 67 | path = modelPath 68 | relations = utils.getRelationsFromContext(ctx, app) 69 | 70 | /** 71 | * HACK: specifically when data is null and GET :model/:id 72 | * is being accessed, we should not treat null as ok. It needs 73 | * to be 404'd and to do that we just exit out of this 74 | * after remote hook and let the middleware chain continue 75 | */ 76 | if (!data && ctx.method.name === 'findById') { 77 | return next() 78 | } 79 | 80 | var primaryKeyField = utils.primaryKeyForModel(model) 81 | var regexRelationFromUrl = /\/.*\/(.*$)/g 82 | var regexMatches = regexRelationFromUrl.exec(ctx.req.path) 83 | var relationName = regexMatches && regexMatches[1] ? regexMatches[1] : null 84 | 85 | var relation = model.relations[relationName] 86 | if (relationName && relation) { 87 | if (relation.polymorphic && utils.relationFkOnModelFrom(relation)) { 88 | let discriminator = relation.polymorphic.discriminator 89 | discriminator = utils.clone(ctx.instance)[discriminator] 90 | relatedModel = app.models[discriminator] 91 | } else { 92 | relatedModel = relation.modelTo 93 | } 94 | relatedModelPlural = utils.pluralForModel(relatedModel) 95 | relatedModelPath = utils.httpPathForModel(relatedModel) 96 | primaryKeyField = utils.primaryKeyForModel(relatedModel) 97 | 98 | if (relatedModelPlural) { 99 | type = relatedModelPlural 100 | model = relatedModel 101 | path = relatedModelPath 102 | relations = model.relations 103 | } 104 | } 105 | 106 | // If we're sideloading, we need to add the includes 107 | if (ctx.req.isSideloadingRelationships) { 108 | requestedIncludes = utils.setRequestedIncludes( 109 | ctx.req.remotingContext.args.filter.include 110 | ) 111 | } 112 | 113 | if (model.definition.settings.scope) { 114 | // bring requestedIncludes in array form 115 | if (typeof requestedIncludes === 'undefined') { 116 | requestedIncludes = [] 117 | } else if (typeof requestedIncludes === 'string') { 118 | requestedIncludes = [requestedIncludes] 119 | } 120 | 121 | // add include from model 122 | var include = model.definition.settings.scope.include 123 | 124 | if (typeof include === 'string') { 125 | requestedIncludes.push(include) 126 | } else if (_.isArray(include)) { 127 | requestedIncludes = requestedIncludes.concat( 128 | utils.setRequestedIncludes(include) 129 | ) 130 | } 131 | } 132 | 133 | options = { 134 | app: app, 135 | model: model, 136 | modelPath: path, 137 | method: ctx.method.name, 138 | meta: ctx.meta ? utils.clone(ctx.meta) : null, 139 | primaryKeyField: primaryKeyField, 140 | requestedIncludes: requestedIncludes, 141 | host: defaults.host || utils.hostFromContext(ctx), 142 | dataLinks: { 143 | self: function (item) { 144 | var urlComponents = url.parse(options.host) 145 | var args = [ 146 | urlComponents.protocol.replace(':', ''), 147 | urlComponents.host, 148 | options.restApiRoot, 149 | path, 150 | item.id 151 | ] 152 | 153 | return utils.buildModelUrl.apply(this, args) 154 | } 155 | } 156 | } 157 | options.topLevelLinks = { 158 | self: options.host + ctx.req.originalUrl 159 | } 160 | 161 | options = _.defaults(options, defaults) 162 | 163 | defaults.debug('===================') 164 | defaults.debug('Serialization input') 165 | defaults.debug('===================') 166 | defaults.debug('JSONAPI TYPE |:', type) 167 | defaults.debug('SERIALIZER OPTS |:', JSON.stringify(options)) 168 | defaults.debug('RELATIONS DEFN |:', JSON.stringify(relations)) 169 | defaults.debug('DATA TO SERIALIZE |:', JSON.stringify(data)) 170 | 171 | // Serialize our request 172 | serializer(type, data, relations, options, function (err, results) { 173 | if (err) return next(err) 174 | 175 | ctx.result = results 176 | 177 | defaults.debug('====================') 178 | defaults.debug('Serialization output') 179 | defaults.debug('====================') 180 | defaults.debug('SERIALIZED DATA |:', JSON.stringify(results)) 181 | next() 182 | }) 183 | }) 184 | } 185 | -------------------------------------------------------------------------------- /lib/update.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var utils = require('./utils') 4 | 5 | module.exports = function (app, options) { 6 | var remotes = app.remotes() 7 | 8 | remotes.after('**', function (ctx, next) { 9 | if (utils.shouldNotApplyJsonApi(ctx, options)) { 10 | return next() 11 | } 12 | 13 | if (ctx.method.name === 'updateAttributes') { 14 | // remote links object from resource response 15 | delete ctx.result.links 16 | } 17 | 18 | next() 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /lib/utilities/relationship-utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var _ = require('lodash') 4 | var statusCodes = require('http-status-codes') 5 | const utils = require('../utils') 6 | 7 | const RELATION_TYPES = Object.freeze({ 8 | HAS_MANY_THROUGH: 0, 9 | HAS_MANY: 1, 10 | HAS_ONE: 2, 11 | BELONGS_TO: 3 12 | }) 13 | 14 | /* global module */ 15 | module.exports = { 16 | getIncludesArray: getIncludesArray, 17 | getInvalidIncludesError: getInvalidIncludesError, 18 | isRequestingIncludes: isRequestingIncludes, 19 | shouldIncludeRelationships: shouldIncludeRelationships, 20 | isLoopbackInclude: isLoopbackInclude, 21 | updateHasMany: updateHasMany, 22 | updateHasOne: updateHasOne, 23 | updateBelongsTo: updateBelongsTo, 24 | updateHasManyThrough: updateHasManyThrough, 25 | detectUpdateStrategy: detectUpdateStrategy, 26 | linkRelatedModels: linkRelatedModels 27 | } 28 | 29 | /** 30 | * Get the invalid includes error. 31 | * @public 32 | * @memberOf {RelationshipUtils} 33 | * @param {String} message 34 | * @return {Error} 35 | */ 36 | function getInvalidIncludesError (message) { 37 | var error = new Error( 38 | message || 'JSON API resource does not support `include`' 39 | ) 40 | error.statusCode = statusCodes.BAD_REQUEST 41 | error.code = statusCodes.BAD_REQUEST 42 | error.status = statusCodes.BAD_REQUEST 43 | 44 | return error 45 | } 46 | 47 | function isLoopbackInclude (ctx) { 48 | return ctx.args && ctx.args.filter 49 | } 50 | 51 | function isJSONAPIInclude (req) { 52 | return _.isPlainObject(req.query) && 53 | req.query.hasOwnProperty('include') && 54 | req.query.include.length > 0 55 | } 56 | 57 | /** 58 | * Is the user requesting to sideload relationships?> 59 | * @public 60 | * @MemberOf {RelationshipUtils} 61 | * @return {Boolean} 62 | */ 63 | function isRequestingIncludes (ctx) { 64 | return isLoopbackInclude(ctx) || isJSONAPIInclude(ctx.req) 65 | } 66 | 67 | /** 68 | * Returns an array of relationships to include. Per JSON specification, they will 69 | * be in a comma-separated pattern. 70 | * @public 71 | * @memberOf {RelationshipUtils} 72 | * @param {Object} query 73 | * @return {Array} 74 | */ 75 | function getIncludesArray (query) { 76 | var relationships = query.include.split(',') 77 | 78 | return relationships.map(function (val) { 79 | return val.trim() 80 | }) 81 | } 82 | 83 | /** 84 | * We should only include relationships if they are using GET 85 | * @public 86 | * @memberOf {RelationshipUtils} 87 | * @return {Boolean} 88 | */ 89 | function shouldIncludeRelationships (method) { 90 | return method.toLowerCase() === 'get' 91 | } 92 | 93 | function updateHasMany ( 94 | leftPKName, 95 | leftPKValue, 96 | RightModel, 97 | rightFKName, 98 | rightFKValues 99 | ) { 100 | return RightModel.updateAll({ [leftPKName]: { inq: rightFKValues } }, { 101 | [rightFKName]: leftPKValue 102 | }) 103 | .then(() => RightModel.find({ where: { [rightFKName]: leftPKValue } })) 104 | .then(models => { 105 | const idsToUnset = _.difference(_.map(models, 'id'), rightFKValues) 106 | return RightModel.updateAll({ id: { inq: idsToUnset } }, { 107 | [rightFKName]: null 108 | }) 109 | }) 110 | } 111 | 112 | function updateHasOne ( 113 | rightPKName, 114 | leftPKValue, 115 | RightModel, 116 | rightFKName, 117 | rightPKId 118 | ) { 119 | return RightModel.updateAll({ [rightFKName]: leftPKValue }, { 120 | [rightFKName]: null 121 | }) 122 | .then(() => { 123 | if (rightPKId) { 124 | return RightModel.updateAll({ [rightPKName]: rightPKId }, { 125 | [rightFKName]: leftPKValue 126 | }) 127 | } 128 | }) 129 | } 130 | 131 | function updateBelongsTo ( 132 | LeftModel, 133 | leftPKName, 134 | leftPKValue, 135 | leftFKName, 136 | rightPKId 137 | ) { 138 | if (rightPKId === null) { 139 | return LeftModel.updateAll({ [leftPKName]: leftPKValue }, { 140 | [leftFKName]: null 141 | }) 142 | } 143 | return LeftModel.updateAll({ [leftPKName]: leftPKValue }, { 144 | [leftFKName]: rightPKId 145 | }) 146 | } 147 | 148 | function updateHasManyThrough ( 149 | leftPKName, 150 | leftPKValue, 151 | PivotModel, 152 | leftFKName, 153 | rightFKName, 154 | rightPKName, 155 | rightFKValues 156 | ) { 157 | return PivotModel.find({ where: { [leftFKName]: leftPKValue } }) 158 | .then(models => { 159 | const existingIds = models.map(model => model[rightFKName]) 160 | const idsToDelete = _.difference(existingIds, rightFKValues) 161 | return PivotModel.destroyAll({ 162 | [leftFKName]: leftPKValue, 163 | [rightFKName]: { inq: idsToDelete } 164 | }) 165 | .then(() => { 166 | const idsToAdd = _.difference(rightFKValues, existingIds) 167 | return PivotModel.create( 168 | idsToAdd.map(id => ({ 169 | [leftFKName]: leftPKValue, 170 | [rightFKName]: id 171 | })) 172 | ) 173 | }) 174 | }) 175 | } 176 | 177 | function detectUpdateStrategy (Model, relationName) { 178 | const relationDefn = Model.relations[relationName] 179 | if (relationDefn.modelThrough) return RELATION_TYPES.HAS_MANY_THROUGH 180 | if (relationDefn.type === 'hasMany') return RELATION_TYPES.HAS_MANY 181 | if (relationDefn.type === 'hasOne') return RELATION_TYPES.HAS_ONE 182 | if (relationDefn.type === 'belongsTo') return RELATION_TYPES.BELONGS_TO 183 | } 184 | 185 | function linkRelatedModels (relationName, from, to) { 186 | const LeftModel = from.model 187 | const id = from.id 188 | const RightModel = to.model 189 | const data = to.data 190 | const relationDefn = LeftModel.relations[relationName] 191 | const strategy = detectUpdateStrategy(LeftModel, relationName) 192 | 193 | if (strategy === RELATION_TYPES.HAS_MANY_THROUGH) { 194 | const leftPKName = utils.primaryKeyForModel(LeftModel) 195 | const rightPKName = utils.primaryKeyForModel(RightModel) 196 | const PivotModel = relationDefn.modelThrough 197 | const leftFKName = relationDefn.keyTo 198 | const rightFKName = relationDefn.keyThrough 199 | return updateHasManyThrough( 200 | leftPKName, 201 | id, 202 | PivotModel, 203 | leftFKName, 204 | rightFKName, 205 | rightPKName, 206 | data 207 | ) 208 | } 209 | 210 | if (strategy === RELATION_TYPES.HAS_MANY) { 211 | const leftPKName = utils.primaryKeyForModel(LeftModel) 212 | const rightFKName = relationDefn.keyTo 213 | return updateHasMany(leftPKName, id, RightModel, rightFKName, data) 214 | } 215 | 216 | if (strategy === RELATION_TYPES.HAS_ONE) { 217 | const rightPKName = utils.primaryKeyForModel(RightModel) 218 | const rightFKName = relationDefn.keyTo 219 | return updateHasOne(rightPKName, id, RightModel, rightFKName, data) 220 | } 221 | 222 | if (strategy === RELATION_TYPES.BELONGS_TO) { 223 | const leftPKName = utils.primaryKeyForModel(LeftModel) 224 | const leftFKName = relationDefn.keyFrom 225 | return updateBelongsTo(LeftModel, leftPKName, id, leftFKName, data) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var url = require('url') 4 | var inflection = require('inflection') 5 | var _ = require('lodash') 6 | 7 | /** 8 | * Public API 9 | */ 10 | module.exports = { 11 | buildModelUrl: buildModelUrl, 12 | clone: clone, 13 | getModelFromContext: getModelFromContext, 14 | getTypeFromContext: getTypeFromContext, 15 | getRelationsFromContext: getRelationsFromContext, 16 | hostFromContext: hostFromContext, 17 | modelNameFromContext: modelNameFromContext, 18 | pluralForModel: pluralForModel, 19 | httpPathForModel: httpPathForModel, 20 | urlFromContext: urlFromContext, 21 | primaryKeyForModel: primaryKeyForModel, 22 | shouldNotApplyJsonApi: shouldNotApplyJsonApi, 23 | shouldApplyJsonApi: shouldApplyJsonApi, 24 | relationFkOnModelFrom: relationFkOnModelFrom, 25 | setRequestedIncludes: setRequestedIncludes, 26 | setIncludedRelations: setIncludedRelations 27 | } 28 | 29 | function primaryKeyForModel (model) { 30 | return model.getIdName() 31 | } 32 | 33 | /** 34 | * Returns the plural for a model. 35 | * @public 36 | * @memberOf {Utils} 37 | * @param {Object} model 38 | * @return {String} 39 | */ 40 | function pluralForModel (model) { 41 | if (model.pluralModelName) { 42 | return model.pluralModelName 43 | } 44 | 45 | if (model.settings && model.settings.plural) { 46 | return model.settings.plural 47 | } 48 | 49 | if ( 50 | model.definition && 51 | model.definition.settings && 52 | model.definition.settings.plural 53 | ) { 54 | return model.definition.settings.plural 55 | } 56 | 57 | return inflection.pluralize(model.sharedClass.name) 58 | } 59 | 60 | /** 61 | * Returns the plural path for a model 62 | * @public 63 | * @memberOf {Utils} 64 | * @param {Object} model 65 | * @return {String} 66 | */ 67 | function httpPathForModel (model) { 68 | if (model.settings && model.settings.http && model.settings.http.path) { 69 | return model.settings.http.path 70 | } 71 | return pluralForModel(model) 72 | } 73 | 74 | /** 75 | * Clones an object by stringifying it and parsing it. 76 | * @public 77 | * @memberOf {Utils} 78 | * @param {Object} object 79 | * @return {Object} 80 | */ 81 | function clone (object) { 82 | return JSON.parse(JSON.stringify(object)) 83 | } 84 | 85 | /** 86 | * Returns a models name from its context. 87 | * @public 88 | * @memberOf {Utils} 89 | * @param {Object} context 90 | * @return {String} 91 | */ 92 | function modelNameFromContext (context) { 93 | return context.method.sharedClass.name 94 | } 95 | 96 | /** 97 | * Returns the fully qualified host 98 | * @public 99 | * @memberOf {Utils} 100 | * @param {Object} context 101 | * @return {String} 102 | */ 103 | function hostFromContext (context) { 104 | return context.req.protocol + '://' + context.req.get('host') 105 | } 106 | 107 | /** 108 | * Returns the fully qualified request url 109 | * @public 110 | * @memberOf {Utils} 111 | * @param {Object} context 112 | * @return {String} 113 | */ 114 | function urlFromContext (context) { 115 | return context.req.protocol + 116 | '://' + 117 | context.req.get('host') + 118 | context.req.originalUrl 119 | } 120 | 121 | /** 122 | * Returns a model from the app object. 123 | * @public 124 | * @memberOf {Utils} 125 | * @param {Object} context 126 | * @param {Object} app 127 | * @return {Object} 128 | */ 129 | function getModelFromContext (context, app) { 130 | var type = getTypeFromContext(context) 131 | if (app.models[type]) return app.models[type] 132 | 133 | var name = modelNameFromContext(context) 134 | return app.models[name] 135 | } 136 | 137 | /** 138 | * Returns a model type from the context object. 139 | * Infer the type from the `root` returns in the remote. 140 | * @public 141 | * @memberOf {Utils} 142 | * @param {Object} context 143 | * @param {Object} app 144 | * @return {String} 145 | */ 146 | function getTypeFromContext (context) { 147 | if (!context.method.returns) return undefined 148 | 149 | const returns = [].concat(context.method.returns) 150 | for (var i = 0, l = returns.length; i < l; i++) { 151 | if (typeof returns[i] !== 'object' || returns[i].root !== true) continue 152 | return returns[i].type 153 | } 154 | } 155 | 156 | /** 157 | * Gets the relations from a context. 158 | * @public 159 | * @memberOf {Utils} 160 | * @param {Object} context 161 | * @param {Object} app 162 | * @return {Object} 163 | * 164 | * Example 165 | { 166 | post: { 167 | name: 'post', 168 | type: 'belongsTo', 169 | modelFrom: [Function: ModelConstructor], 170 | keyFrom: 'postId', 171 | modelTo: [Function: ModelConstructor], 172 | keyTo: 'id', 173 | polymorphic: undefined, 174 | modelThrough: undefined, 175 | keyThrough: undefined, 176 | multiple: false, 177 | properties: {}, 178 | options: {}, 179 | scope: undefined, 180 | embed: false, 181 | methods: {} 182 | } 183 | } 184 | */ 185 | function getRelationsFromContext (context, app) { 186 | var model = getModelFromContext(context, app) 187 | return model.relations 188 | } 189 | 190 | /** 191 | * Builds a models url 192 | * @public 193 | * @memberOf {Utils} 194 | * @param {String} protocol 195 | * @param {String} host 196 | * @param {String} apiRoot 197 | * @param {String} modelName 198 | * @param {String|Number} id 199 | * @return {String} 200 | */ 201 | function buildModelUrl (protocol, host, apiRoot, modelName, id) { 202 | var result 203 | 204 | try { 205 | result = url.format({ 206 | protocol: protocol, 207 | host: host, 208 | pathname: url.resolve('/', [apiRoot, modelName, id].join('/')) 209 | }) 210 | } catch (e) { 211 | return '' 212 | } 213 | 214 | return result 215 | } 216 | 217 | function shouldApplyJsonApi (ctx, options) { 218 | // include on remote have higher priority 219 | if (ctx.method.jsonapi) return !!ctx.method.jsonapi 220 | 221 | var modelName = ctx.method.sharedClass.name 222 | var methodName = ctx.method.name 223 | var model 224 | var methods 225 | if (options.include) { 226 | for (var i = 0; i < options.include.length; i++) { 227 | model = options.include[i].model 228 | methods = options.include[i].methods 229 | if (model === modelName && !methods) return true 230 | if (!model && methods === methodName) return true 231 | if (model === modelName && methods === methodName) return true 232 | if (model === modelName && _.includes(methods, methodName)) return true 233 | if (!model && _.includes(methods, methodName)) return true 234 | } 235 | } 236 | 237 | // a default option can be set in component-config 238 | return !!options.handleCustomRemoteMethods 239 | } 240 | 241 | function shouldNotApplyJsonApi (ctx, options) { 242 | // exclude on remote have higher priority 243 | if (ctx.method.jsonapi === false) return true 244 | 245 | // handle options.exclude 246 | if (!options.exclude) return false 247 | 248 | var modelName = ctx.method.sharedClass.name 249 | var methodName = ctx.method.name 250 | var model 251 | var methods 252 | 253 | for (var i = 0; i < options.exclude.length; i++) { 254 | model = options.exclude[i].model 255 | methods = options.exclude[i].methods 256 | if (model === modelName && !methods) return true 257 | if (!model && methods === methodName) return true 258 | if (model === modelName && methods === methodName) return true 259 | if (model === modelName && _.includes(methods, methodName)) return true 260 | if (!model && _.includes(methods, methodName)) return true 261 | } 262 | 263 | return false 264 | } 265 | 266 | function relationFkOnModelFrom (relation) { 267 | return relation.type === 'belongsTo' || relation.type === 'referencesMany' 268 | } 269 | 270 | function setIncludedRelations (relations, app) { 271 | for (var key in relations) { 272 | if (relations.hasOwnProperty(key)) { 273 | var name = (relations[key].modelTo && 274 | relations[key].modelTo.sharedClass.name) || 275 | relations[key].name 276 | relations[key].relations = app.models[name] && app.models[name].relations 277 | } 278 | } 279 | return relations 280 | } 281 | 282 | function setRequestedIncludes (include) { 283 | if (!include) return undefined 284 | 285 | if (typeof include === 'string') { 286 | return include 287 | } 288 | if (include instanceof Array) { 289 | return include.map(function (inc) { 290 | if (typeof inc === 'string') { 291 | return inc 292 | } 293 | 294 | if (inc instanceof Object) { 295 | return inc.relation 296 | } 297 | }) 298 | } 299 | return include 300 | } 301 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-component-jsonapi", 3 | "description": "JSONAPI support for loopback", 4 | "version": "0.0.0-development", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "npm run lint && istanbul cover _mocha --report lcovonly --reporter=spec ./test/**/*.test.js && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 8 | "tester": "mocha --reporter=spec ./test/**/*.test.js", 9 | "coverage": "istanbul cover _mocha ./test/**/*.test.js", 10 | "lint": "standard './test/**/*.js' './lib/**/*.js' --verbose | snazzy", 11 | "semantic-release": "semantic-release", 12 | "travis-deploy-once": "travis-deploy-once", 13 | "precommit": "lint-staged", 14 | "formatter": "prettier-standard-formatter .", 15 | "commit": "git-cz", 16 | "commit:retry": "git-cz --retry", 17 | "commitmsg": "commitlint -e" 18 | }, 19 | "lint-staged": { 20 | "*.js": [ 21 | "npm run formatter", 22 | "git add" 23 | ] 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/digitalsadhu/loopback-component-jsonapi.git" 28 | }, 29 | "keywords": [ 30 | "loopback", 31 | "component", 32 | "jsonapi", 33 | "api", 34 | "json" 35 | ], 36 | "author": "Richard Walker ", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/digitalsadhu/loopback-component-jsonapi/issues" 40 | }, 41 | "homepage": "https://github.com/digitalsadhu/loopback-component-jsonapi#readme", 42 | "dependencies": { 43 | "body-parser": "^1.18.2", 44 | "debug": "^3.1.0", 45 | "http-status-codes": "^1.3.0", 46 | "inflection": "^1.7.2", 47 | "lint-staged": "^6.0.0", 48 | "lodash": "^4.17.1", 49 | "prettier-standard-formatter": "^0.222222222222222.333333333333333", 50 | "snazzy": "^7.0.0", 51 | "type-is": "^1.6.14" 52 | }, 53 | "devDependencies": { 54 | "@commitlint/cli": "^6.0.2", 55 | "@commitlint/config-conventional": "^6.0.2", 56 | "@commitlint/prompt": "^6.0.2", 57 | "chai": "^4.1.2", 58 | "commitizen": "^2.9.6", 59 | "coveralls": "^3.0.0", 60 | "husky": "^0.14.3", 61 | "istanbul": "^0.4.5", 62 | "loopback": "^3.16.2", 63 | "loopback-datasource-juggler": "^3.13.0", 64 | "mocha": "^4.0.1", 65 | "rsvp": "4.7.0", 66 | "semantic-release": "^12.2.2", 67 | "standard": "^10.0.3", 68 | "supertest": "^3.0.0", 69 | "travis-deploy-once": "^4.3.1" 70 | }, 71 | "standard": { 72 | "globals": [ 73 | "beforeEach", 74 | "it", 75 | "describe", 76 | "afterEach" 77 | ] 78 | }, 79 | "config": { 80 | "commitizen": { 81 | "path": "node_modules/@commitlint/prompt" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/belongsToPolymorphic.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var ds 9 | var Post 10 | var FileModel 11 | 12 | describe('loopback json api belongsTo polymorphic relationships', function () { 13 | beforeEach(function () { 14 | app = loopback() 15 | app.set('legacyExplorer', false) 16 | ds = loopback.createDataSource('memory') 17 | Post = ds.createModel('post', { 18 | id: { type: Number, id: true }, 19 | title: String, 20 | content: String 21 | }) 22 | app.model(Post) 23 | 24 | FileModel = ds.createModel('fileModel', { 25 | id: { type: Number, id: true }, 26 | fileName: String, 27 | parentId: Number, 28 | parentType: String 29 | }) 30 | FileModel.belongsTo('parent', { 31 | polymorphic: { 32 | foreignKey: 'parentId', 33 | discriminator: 'parentType' 34 | } 35 | }) 36 | app.model(FileModel) 37 | 38 | app.use(loopback.rest()) 39 | JSONAPIComponent(app) 40 | }) 41 | 42 | describe('File belonging to a Post', function () { 43 | beforeEach(function (done) { 44 | Post.create( 45 | { 46 | title: 'Post One', 47 | content: 'Content' 48 | }, 49 | function (err, post) { 50 | expect(err).to.equal(null) 51 | FileModel.create( 52 | { 53 | fileName: 'blah.jpg', 54 | parentId: post.id, 55 | parentType: 'post' 56 | }, 57 | done 58 | ) 59 | } 60 | ) 61 | }) 62 | 63 | it('should have a relationship to Post', function (done) { 64 | request(app).get('/fileModels/1').end(function (err, res) { 65 | expect(err).to.equal(null) 66 | expect(res.body).to.not.have.key('errors') 67 | expect(res.body.data.relationships.parent).to.be.an('object') 68 | done() 69 | }) 70 | }) 71 | 72 | it( 73 | 'should return the Post that this file belongs to when included flag is present', 74 | function (done) { 75 | request(app) 76 | .get('/fileModels/1?include=parent') 77 | .end(function (err, res) { 78 | expect(err).to.equal(null) 79 | expect(res.body).to.not.have.key('errors') 80 | expect(res.body.included).to.be.an('array') 81 | expect(res.body.included[0].type).to.equal('posts') 82 | expect(res.body.included[0].id).to.equal('1') 83 | done() 84 | }) 85 | } 86 | ) 87 | 88 | it( 89 | 'should return the Post that this file belongs to when following the relationship link', 90 | function (done) { 91 | request(app).get('/fileModels/1').end(function (err, res) { 92 | expect(err).to.equal(null) 93 | expect(res.body).to.not.have.key('errors') 94 | request(app) 95 | .get( 96 | res.body.data.relationships.parent.links.related.split('api')[1] 97 | ) 98 | .end(function (err, res) { 99 | expect(err).to.equal(null) 100 | expect(res.body).to.not.have.key('errors') 101 | expect(res.body.data.type).to.equal('posts') 102 | expect(res.body.data.id).to.equal('1') 103 | done() 104 | }) 105 | }) 106 | } 107 | ) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/belongsToThroughHasMany.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app, Rule, Action, Card, ds 8 | 9 | describe('belongsTo through hasMany', function () { 10 | beforeEach(function () { 11 | app = loopback() 12 | app.set('legacyExplorer', false) 13 | ds = loopback.createDataSource('memory') 14 | 15 | Rule = ds.createModel('rule', { 16 | id: { type: Number, id: true }, 17 | name: String 18 | }) 19 | 20 | app.model(Rule) 21 | 22 | Action = ds.createModel('action', { 23 | id: { type: Number, id: true }, 24 | cardId: Number, 25 | name: String 26 | }) 27 | 28 | app.model(Action) 29 | 30 | Card = ds.createModel('card', { 31 | id: { type: Number, id: true }, 32 | title: String 33 | }) 34 | 35 | app.model(Card) 36 | 37 | Rule.hasMany(Action, { as: 'actions', foreignKey: 'ruleId' }) 38 | Action.belongsTo(Card, { as: 'card', foreignKey: 'cardId' }) 39 | Card.hasMany(Action, { as: 'actions', foreignKey: 'cardId' }) 40 | app.use(loopback.rest()) 41 | JSONAPIComponent(app, { restApiRoot: '/' }) 42 | }) 43 | 44 | describe('relationship should point to its relationship', function () { 45 | beforeEach(function (done) { 46 | Rule.create( 47 | { 48 | name: 'Rule 1' 49 | }, 50 | function (err, rule) { 51 | expect(err).to.equal(null) 52 | rule.actions.create( 53 | [{ name: 'Action 1', cardId: 1 }, { name: 'Action 2', cardId: 1 }], 54 | function (err, res) { 55 | expect(err).to.equal(null) 56 | Card.create([{ title: 'Card 1' }], done) 57 | } 58 | ) 59 | } 60 | ) 61 | }) 62 | 63 | it('GET /rules/1/actions', function (done) { 64 | request(app).get('/rules/1/actions').end(function (err, res) { 65 | expect(err).to.equal(null) 66 | expect(res.body.data[0].relationships.card).to.be.an('object') 67 | expect(res.body.data[0].relationships.card.links).to.be.an('object') 68 | expect(res.body.data[0].relationships.card.links.related).to.match( 69 | /\/actions\/1\/card/ 70 | ) 71 | expect(res.body.data[1].relationships.card.links.related).to.match( 72 | /\/actions\/2\/card/ 73 | ) 74 | done() 75 | }) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /test/config.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | 8 | var app 9 | var Image 10 | 11 | describe('Dont override config.js ', function () { 12 | beforeEach(function () { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | app.use(loopback.rest()) 16 | 17 | var remotes = app.remotes() 18 | remotes.options.json = { limit: '100B' } 19 | var ds = loopback.createDataSource('memory') 20 | Image = ds.createModel('image', { 21 | id: { type: Number, id: true }, 22 | source: String 23 | }) 24 | 25 | app.model(Image) 26 | 27 | JSONAPIComponent(app) 28 | }) 29 | 30 | it('should have limit property', function (done) { 31 | var remotes = app.remotes() 32 | expect(remotes.options.json).to.have.any.keys('limit') 33 | done() 34 | }) 35 | 36 | it('should accept payload < 100B', function (done) { 37 | request(app) 38 | .post('/images') 39 | .send({ 40 | data: { 41 | type: 'images', 42 | attributes: { 43 | source: 'a' 44 | } 45 | } 46 | }) 47 | .set('Accept', 'application/vnd.api+json') 48 | .set('Content-Type', 'application/json') 49 | .expect(201) 50 | .end(done) 51 | }) 52 | 53 | it('should not accept payload > 100B', function (done) { 54 | request(app) 55 | .post('/images') 56 | .send({ 57 | data: { 58 | type: 'images', 59 | attributes: { 60 | source: 'A long text to make the payload greater then 100B' 61 | } 62 | } 63 | }) 64 | .set('Accept', 'application/vnd.api+json') 65 | .set('Content-Type', 'application/json') 66 | .expect(413) 67 | .end(done) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/create.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('loopback json api component create method', function () { 12 | beforeEach(function () { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | Post = ds.createModel('post', { 17 | id: { type: Number, id: true }, 18 | title: String, 19 | content: String 20 | }) 21 | app.model(Post) 22 | 23 | Comment = ds.createModel('comment', { 24 | id: { type: Number, id: true }, 25 | content: String 26 | }) 27 | app.model(Comment) 28 | 29 | app.use(loopback.rest()) 30 | JSONAPIComponent(app, { restApiRoot: '' }) 31 | }) 32 | 33 | describe('headers', function () { 34 | it( 35 | 'POST /models should be created when Accept header is set to application/vnd.api+json', 36 | function (done) { 37 | request(app) 38 | .post('/posts') 39 | .send({ 40 | data: { 41 | type: 'posts', 42 | attributes: { 43 | title: 'my post', 44 | content: 'my post content' 45 | } 46 | } 47 | }) 48 | .set('Accept', 'application/vnd.api+json') 49 | .set('Content-Type', 'application/json') 50 | .expect(201) 51 | .end(done) 52 | } 53 | ) 54 | it( 55 | 'POST /models should have the JSON API Content-Type header set on response', 56 | function () { 57 | var data = { 58 | data: { 59 | type: 'posts', 60 | attributes: { 61 | title: 'my post', 62 | content: 'my post content' 63 | } 64 | } 65 | } 66 | 67 | return request(app) 68 | .post('/posts') 69 | .send(data) 70 | .set('accept', 'application/vnd.api+json') 71 | .set('content-type', 'application/vnd.api+json') 72 | .then(res => { 73 | expect(res.headers['content-type']).to.match( 74 | /application\/vnd\.api\+json/ 75 | ) 76 | expect(res.statusCode).to.equal(201) 77 | expect(res.body).to.have.all.keys('data') 78 | expect(res.body.data).to.have.all.keys( 79 | 'type', 80 | 'id', 81 | 'links', 82 | 'attributes' 83 | ) 84 | }) 85 | } 86 | ) 87 | 88 | it('POST /models should have the Location header set on response', function ( 89 | done 90 | ) { 91 | request(app) 92 | .post('/posts') 93 | .send({ 94 | data: { 95 | type: 'posts', 96 | attributes: { 97 | title: 'my post', 98 | content: 'my post content' 99 | } 100 | } 101 | }) 102 | .set('Content-Type', 'application/json') 103 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 104 | .expect('Location', /^http:\/\/127\.0\.0\.1.*\/posts\/1/) 105 | .end(done) 106 | }) 107 | }) 108 | 109 | describe('status codes', function () { 110 | it('POST /models should return a 201 CREATED status code', function (done) { 111 | request(app) 112 | .post('/posts') 113 | .send({ 114 | data: { 115 | type: 'posts', 116 | attributes: { 117 | title: 'my post', 118 | content: 'my post content' 119 | } 120 | } 121 | }) 122 | .set('Content-Type', 'application/json') 123 | .expect(201) 124 | .end(done) 125 | }) 126 | }) 127 | 128 | describe('self links', function () { 129 | it('should produce resource level self links', function (done) { 130 | request(app) 131 | .post('/posts') 132 | .send({ 133 | data: { 134 | type: 'posts', 135 | attributes: { 136 | title: 'my post', 137 | content: 'my post content' 138 | } 139 | } 140 | }) 141 | .set('Content-Type', 'application/json') 142 | .end(function (err, res) { 143 | expect(err).to.equal(null) 144 | expect(res.body.data.links.self).to.match( 145 | /http:\/\/127\.0\.0\.1.*\/posts\/1/ 146 | ) 147 | done() 148 | }) 149 | }) 150 | }) 151 | 152 | describe('Creating a resource using POST /models', function () { 153 | it('POST /models should return a correct JSON API response', function ( 154 | done 155 | ) { 156 | request(app) 157 | .post('/posts') 158 | .send({ 159 | data: { 160 | type: 'posts', 161 | attributes: { 162 | title: 'my post', 163 | content: 'my post content' 164 | } 165 | } 166 | }) 167 | .set('Content-Type', 'application/json') 168 | .end(function (err, res) { 169 | expect(err).to.equal(null) 170 | expect(res.body).to.have.all.keys('data') 171 | expect(res.body.data).to.have.all.keys( 172 | 'id', 173 | 'type', 174 | 'attributes', 175 | 'links' 176 | ) 177 | expect(res.body.data.id).to.equal('1') 178 | expect(res.body.data.type).to.equal('posts') 179 | expect(res.body.data.attributes).to.have.all.keys('title', 'content') 180 | expect(res.body.data.attributes).to.not.have.keys('id') 181 | done() 182 | }) 183 | }) 184 | 185 | it('POST /models with null relationship data', function (done) { 186 | request(app) 187 | .post('/posts') 188 | .send({ 189 | data: { 190 | type: 'posts', 191 | attributes: { 192 | title: 'my post', 193 | content: 'my post content' 194 | }, 195 | relationships: { 196 | comments: { 197 | data: null 198 | } 199 | } 200 | } 201 | }) 202 | .set('Content-Type', 'application/json') 203 | .end(function (err, res) { 204 | expect(err).to.equal(null) 205 | expect(res.body).to.have.all.keys('data') 206 | expect(res.body.data).to.have.all.keys( 207 | 'id', 208 | 'type', 209 | 'attributes', 210 | 'links' 211 | ) 212 | expect(res.body.data.id).to.equal('1') 213 | expect(res.body.data.type).to.equal('posts') 214 | expect(res.body.data.attributes).to.have.all.keys('title', 'content') 215 | expect(res.body.data.attributes).to.not.have.keys('id') 216 | done() 217 | }) 218 | }) 219 | }) 220 | }) 221 | -------------------------------------------------------------------------------- /test/delete.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var JSONAPIComponent = require('../') 6 | var app 7 | var Post 8 | 9 | describe('loopback json api component delete method', function () { 10 | beforeEach(function (done) { 11 | app = loopback() 12 | app.set('legacyExplorer', false) 13 | var ds = loopback.createDataSource('memory') 14 | Post = ds.createModel('post', { 15 | id: { type: Number, id: true }, 16 | title: String, 17 | content: String 18 | }) 19 | app.model(Post) 20 | app.use(loopback.rest()) 21 | JSONAPIComponent(app) 22 | Post.create( 23 | { 24 | title: 'my post', 25 | content: 'my post content' 26 | }, 27 | done 28 | ) 29 | }) 30 | 31 | describe('status code', function () { 32 | it('DELETE /models/:id should return a 204 NO CONTENT', function (done) { 33 | request(app).delete('/posts/1').expect(204).end(done) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/errors.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var _ = require('lodash') 6 | var expect = require('chai').expect 7 | var JSONAPIComponent = require('../') 8 | var app 9 | var Post 10 | 11 | describe('disabling loopback-component-jsonapi error handler', function () { 12 | it('should retain the default error handler', function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | Post = ds.createModel('post', { 17 | id: { type: Number, id: true }, 18 | title: String, 19 | content: String 20 | }) 21 | app.model(Post) 22 | app.use(loopback.rest()) 23 | JSONAPIComponent(app, { handleErrors: false }) 24 | 25 | request(app).get('/posts/100').end(function (err, res) { 26 | expect(err).to.equal(null) 27 | expect(res.body).to.have.keys('error') 28 | expect(res.body.error).to.contain.keys('name', 'message', 'statusCode') 29 | done() 30 | }) 31 | }) 32 | }) 33 | 34 | describe('loopback json api errors', function () { 35 | beforeEach(function () { 36 | app = loopback() 37 | app.set('legacyExplorer', false) 38 | var ds = loopback.createDataSource('memory') 39 | Post = ds.createModel('post', { 40 | id: { type: Number, id: true }, 41 | title: String, 42 | content: String 43 | }) 44 | app.model(Post) 45 | app.use(loopback.rest()) 46 | JSONAPIComponent(app, { restApiRoot: '' }) 47 | }) 48 | 49 | describe('headers', function () { 50 | it( 51 | "GET /models/100 (which doens't exist) should return JSON API header", 52 | function (done) { 53 | request(app) 54 | .get('/posts/100') 55 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 56 | .end(done) 57 | } 58 | ) 59 | 60 | it( 61 | 'POST /models should return JSON API header when type key not present error is thrown', 62 | function (done) { 63 | request(app) 64 | .post('/posts') 65 | .send({ 66 | data: { 67 | attributes: { title: 'my post', content: 'my post content' } 68 | } 69 | }) 70 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 71 | .set('Content-Type', 'application/json') 72 | .end(done) 73 | } 74 | ) 75 | }) 76 | 77 | describe('status codes', function () { 78 | it('GET /models/100 should return a 404 not found error', function (done) { 79 | request(app).get('/posts/100').expect(404).end(done) 80 | }) 81 | 82 | it('GET /models/100 should return a 404 not found error', function (done) { 83 | request(app).get('/posts/100').end(function (err, res) { 84 | expect(err).to.equal(null) 85 | expect(res.body).to.have.keys('errors') 86 | expect(res.body.errors.length).to.equal(1) 87 | expect(res.body.errors[0]).to.deep.equal({ 88 | status: 404, 89 | code: 'MODEL_NOT_FOUND', 90 | detail: 'Unknown "post" id "100".', 91 | source: {}, 92 | title: 'Error' 93 | }) 94 | done() 95 | }) 96 | }) 97 | 98 | it( 99 | 'POST /models should return a 422 status code if type key is not present', 100 | function (done) { 101 | request(app) 102 | .post('/posts') 103 | .send({ 104 | data: { 105 | attributes: { title: 'my post', content: 'my post content' } 106 | } 107 | }) 108 | .expect(422) 109 | .set('Content-Type', 'application/json') 110 | .end(done) 111 | } 112 | ) 113 | 114 | it( 115 | 'POST /models should return a more specific 422 status code on the error object if type key is not present', 116 | function (done) { 117 | request(app) 118 | .post('/posts') 119 | .send({ 120 | data: { 121 | attributes: { title: 'my post', content: 'my post content' } 122 | } 123 | }) 124 | .set('Content-Type', 'application/json') 125 | .end(function (err, res) { 126 | expect(err).to.equal(null) 127 | expect(res.body).to.have.keys('errors') 128 | expect(res.body.errors.length).to.equal(1) 129 | expect(res.body.errors[0]).to.deep.equal({ 130 | status: 422, 131 | code: 'presence', 132 | detail: 'JSON API resource object must contain `data.type` property', 133 | source: {}, 134 | title: 'ValidationError' 135 | }) 136 | done() 137 | }) 138 | } 139 | ) 140 | 141 | it( 142 | 'POST /models should return an 422 error if model title is not present', 143 | function (done) { 144 | Post.validatesPresenceOf('title') 145 | 146 | request(app) 147 | .post('/posts') 148 | .send({ data: { type: 'posts' } }) 149 | .expect(422) 150 | .set('Content-Type', 'application/json') 151 | .end(done) 152 | } 153 | ) 154 | 155 | it( 156 | 'POST /models should return an 422 error on the error object if model title is not present', 157 | function (done) { 158 | Post.validatesPresenceOf('title') 159 | Post.validatesPresenceOf('content') 160 | 161 | request(app) 162 | .post('/posts') 163 | .send({ data: { type: 'posts' } }) 164 | .set('Content-Type', 'application/json') 165 | .end(function (err, res) { 166 | expect(err).to.equal(null) 167 | expect(res.body).to.have.keys('errors') 168 | expect(res.body.errors.length).to.equal(2) 169 | expect(res.body.errors[0]).to.deep.equal({ 170 | status: 422, 171 | source: { pointer: 'data/attributes/title' }, 172 | title: 'ValidationError', 173 | code: 'presence', 174 | detail: "can't be blank" 175 | }) 176 | done() 177 | }) 178 | } 179 | ) 180 | }) 181 | }) 182 | 183 | describe('loopback json api errors with advanced reporting', function () { 184 | var errorMetaMock = { 185 | status: 418, 186 | meta: { rfc: 'RFC2324' }, 187 | code: "i'm a teapot", 188 | detail: 'April 1st, 1998', 189 | title: "I'm a teapot", 190 | source: { model: 'Post', method: 'find' } 191 | } 192 | 193 | beforeEach(function () { 194 | app = loopback() 195 | app.set('legacyExplorer', false) 196 | var ds = loopback.createDataSource('memory') 197 | Post = ds.createModel('post', { 198 | id: { type: Number, id: true }, 199 | title: String, 200 | content: String 201 | }) 202 | 203 | Post.find = function () { 204 | var err = new Error(errorMetaMock.detail) 205 | err.name = errorMetaMock.title 206 | err.meta = errorMetaMock.meta 207 | err.source = errorMetaMock.source 208 | err.statusCode = errorMetaMock.status 209 | err.code = errorMetaMock.code 210 | throw err 211 | } 212 | 213 | app.model(Post) 214 | app.use(loopback.rest()) 215 | JSONAPIComponent(app, { restApiRoot: '', errorStackInResponse: true }) 216 | }) 217 | 218 | it( 219 | 'should return the given meta and source in the error response when an Error with a meta and source object is thrown', 220 | function (done) { 221 | request(app) 222 | .get('/posts') 223 | .set('Content-Type', 'application/json') 224 | .end(function (err, res) { 225 | expect(err).to.equal(null) 226 | expect(res.body).to.have.keys('errors') 227 | expect(res.body.errors.length).to.equal(1) 228 | 229 | expect(_.omit(res.body.errors[0], 'source.stack')).to.deep.equal( 230 | errorMetaMock 231 | ) 232 | done() 233 | }) 234 | } 235 | ) 236 | 237 | it( 238 | 'should return the corresponding stack in error when `errorStackInResponse` enabled', 239 | function (done) { 240 | request(app) 241 | .post('/posts') 242 | .send({ 243 | data: { 244 | attributes: { title: 'my post', content: 'my post content' } 245 | } 246 | }) 247 | .set('Content-Type', 'application/json') 248 | .end(function (err, res) { 249 | expect(err).to.equal(null) 250 | expect(res.body).to.have.keys('errors') 251 | expect(res.body.errors.length).to.equal(1) 252 | 253 | expect(res.body.errors[0].source).to.haveOwnProperty('stack') 254 | expect(res.body.errors[0].source.stack.length).to.be.above(100) 255 | 256 | expect(_.omit(res.body.errors[0], 'source')).to.deep.equal({ 257 | status: 422, 258 | code: 'presence', 259 | detail: 'JSON API resource object must contain `data.type` property', 260 | title: 'ValidationError' 261 | }) 262 | done() 263 | }) 264 | } 265 | ) 266 | }) 267 | -------------------------------------------------------------------------------- /test/exclude.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('exclude option', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { title: String }) 18 | app.model(Post) 19 | 20 | Comment = ds.createModel('comment', { comment: String }) 21 | app.model(Comment) 22 | 23 | app.use(loopback.rest()) 24 | 25 | Post.create({ title: 'my post' }, function () { 26 | Comment.create({ comment: 'my comment' }, done) 27 | }) 28 | }) 29 | 30 | describe('excluding a specific model', function () { 31 | beforeEach(function () { 32 | JSONAPIComponent(app, { 33 | exclude: [{ model: 'post' }] 34 | }) 35 | }) 36 | it('should not apply jsonapi to post model method input', function (done) { 37 | request(app) 38 | .post('/posts') 39 | .send({ title: 'my post' }) 40 | .set('Accept', 'application/vnd.api+json') 41 | .set('Content-Type', 'application/json') 42 | .expect(200) 43 | .end(function (err, res) { 44 | expect(err).to.equal(null) 45 | expect(res.body).to.deep.equal({ title: 'my post', id: 2 }) 46 | done() 47 | }) 48 | }) 49 | it('should not apply jsonapi to post model output', function (done) { 50 | request(app).get('/posts').expect(200).end(function (err, res) { 51 | expect(err).to.equal(null) 52 | expect(res.body).to.deep.equal([{ title: 'my post', id: 1 }]) 53 | done() 54 | }) 55 | }) 56 | it('should apply jsonapi to comment model method output', function (done) { 57 | request(app).get('/comments').expect(200).end(function (err, res) { 58 | expect(err).to.equal(null) 59 | expect(res.body.data[0]).to.have.keys( 60 | 'type', 61 | 'id', 62 | 'attributes', 63 | 'links' 64 | ) 65 | done() 66 | }) 67 | }) 68 | }) 69 | 70 | describe('excluding a specific method', function () { 71 | beforeEach(function () { 72 | JSONAPIComponent(app, { 73 | exclude: [{ methods: 'find' }] 74 | }) 75 | }) 76 | 77 | it( 78 | 'should not apply jsonapi to post model output for find method', 79 | function (done) { 80 | request(app).get('/posts').expect(200).end(function (err, res) { 81 | expect(err).to.equal(null) 82 | expect(res.body).to.deep.equal([{ title: 'my post', id: 1 }]) 83 | done() 84 | }) 85 | } 86 | ) 87 | 88 | it( 89 | 'should not apply jsonapi to comment model output for find method', 90 | function (done) { 91 | request(app).get('/comments').expect(200).end(function (err, res) { 92 | expect(err).to.equal(null) 93 | expect(res.body).to.deep.equal([{ comment: 'my comment', id: 1 }]) 94 | done() 95 | }) 96 | } 97 | ) 98 | 99 | it( 100 | 'should apply jsonapi to post model output for findById method', 101 | function (done) { 102 | request(app).get('/posts/1').expect(200).end(function (err, res) { 103 | expect(err).to.equal(null) 104 | expect(res.body.data).to.have.keys( 105 | 'type', 106 | 'id', 107 | 'attributes', 108 | 'links' 109 | ) 110 | done() 111 | }) 112 | } 113 | ) 114 | 115 | it( 116 | 'should apply jsonapi to comment model output for findById method', 117 | function (done) { 118 | request(app).get('/comments/1').expect(200).end(function (err, res) { 119 | expect(err).to.equal(null) 120 | expect(res.body.data).to.have.keys( 121 | 'type', 122 | 'id', 123 | 'attributes', 124 | 'links' 125 | ) 126 | done() 127 | }) 128 | } 129 | ) 130 | }) 131 | 132 | describe('excluding a specific method on a specific model', function () { 133 | beforeEach(function () { 134 | JSONAPIComponent(app, { 135 | exclude: [{ model: 'post', methods: 'find' }] 136 | }) 137 | }) 138 | 139 | it( 140 | 'should not apply jsonapi to post model output for find method', 141 | function (done) { 142 | request(app).get('/posts').expect(200).end(function (err, res) { 143 | expect(err).to.equal(null) 144 | expect(res.body).to.deep.equal([{ title: 'my post', id: 1 }]) 145 | done() 146 | }) 147 | } 148 | ) 149 | 150 | it( 151 | 'should apply jsonapi to post model output for findById method', 152 | function (done) { 153 | request(app).get('/posts/1').expect(200).end(function (err, res) { 154 | expect(err).to.equal(null) 155 | expect(res.body.data).to.have.keys( 156 | 'type', 157 | 'id', 158 | 'attributes', 159 | 'links' 160 | ) 161 | done() 162 | }) 163 | } 164 | ) 165 | 166 | it('should apply jsonapi to comment model output for find method', function ( 167 | done 168 | ) { 169 | request(app).get('/comments').expect(200).end(function (err, res) { 170 | expect(err).to.equal(null) 171 | expect(res.body.data[0]).to.have.keys( 172 | 'type', 173 | 'id', 174 | 'attributes', 175 | 'links' 176 | ) 177 | done() 178 | }) 179 | }) 180 | }) 181 | 182 | describe( 183 | 'excluding a specific set of methods on a specific model', 184 | function () { 185 | beforeEach(function () { 186 | JSONAPIComponent(app, { 187 | exclude: [{ model: 'post', methods: ['find', 'findById'] }] 188 | }) 189 | }) 190 | 191 | it( 192 | 'should not apply jsonapi to post model output for find method', 193 | function (done) { 194 | request(app).get('/posts').expect(200).end(function (err, res) { 195 | expect(err).to.equal(null) 196 | expect(res.body).to.deep.equal([{ title: 'my post', id: 1 }]) 197 | done() 198 | }) 199 | } 200 | ) 201 | 202 | it( 203 | 'should not apply jsonapi to post model output for findById method', 204 | function (done) { 205 | request(app).get('/posts/1').expect(200).end(function (err, res) { 206 | expect(err).to.equal(null) 207 | expect(res.body).to.deep.equal({ title: 'my post', id: 1 }) 208 | done() 209 | }) 210 | } 211 | ) 212 | } 213 | ) 214 | }) 215 | -------------------------------------------------------------------------------- /test/find.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | 10 | describe('loopback json api component find methods', function () { 11 | beforeEach(function () { 12 | app = loopback() 13 | app.set('legacyExplorer', false) 14 | var ds = loopback.createDataSource('memory') 15 | Post = ds.createModel('post', { 16 | id: { type: Number, id: true }, 17 | title: String, 18 | content: String 19 | }) 20 | app.model(Post) 21 | app.use(loopback.rest()) 22 | JSONAPIComponent(app) 23 | }) 24 | 25 | describe('Headers', function () { 26 | it( 27 | 'GET /models should have the JSON API Content-Type header set on collection responses', 28 | function (done) { 29 | request(app) 30 | .get('/posts') 31 | .expect(200) 32 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 33 | .end(done) 34 | } 35 | ) 36 | 37 | it( 38 | 'GET /models/:id should have the JSON API Content-Type header set on individual resource responses', 39 | function (done) { 40 | request(app) 41 | .get('/posts/1') 42 | .expect(404) 43 | .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 44 | .end(done) 45 | } 46 | ) 47 | }) 48 | 49 | describe('Relationship objects', function () { 50 | beforeEach(function (done) { 51 | Post.create( 52 | { 53 | title: 'my post', 54 | content: 'my post content' 55 | }, 56 | function () { 57 | Post.create( 58 | { 59 | title: 'my post 2', 60 | content: 'my post content 2' 61 | }, 62 | done 63 | ) 64 | } 65 | ) 66 | }) 67 | 68 | it( 69 | 'should not be present when no relationships have been defined on a collection', 70 | function (done) { 71 | request(app).get('/posts').end(function (err, res) { 72 | expect(err).to.equal(null) 73 | expect(res.body.data[0]).not.to.have.all.keys('relationships') 74 | done() 75 | }) 76 | } 77 | ) 78 | 79 | it( 80 | 'should not be present when no relationships have been defined on an individual resource', 81 | function (done) { 82 | request(app).get('/posts/1').end(function (err, res) { 83 | expect(err).to.equal(null) 84 | expect(res.body.data).not.to.have.all.keys('relationships') 85 | done() 86 | }) 87 | } 88 | ) 89 | }) 90 | 91 | describe('Self links', function () { 92 | beforeEach(function (done) { 93 | Post.create( 94 | { 95 | title: 'my post', 96 | content: 'my post content' 97 | }, 98 | function () { 99 | Post.create( 100 | { 101 | title: 'my post 2', 102 | content: 'my post content 2' 103 | }, 104 | function () { 105 | Post.create( 106 | { 107 | title: 'my post 3', 108 | content: 'my post content 3' 109 | }, 110 | done 111 | ) 112 | } 113 | ) 114 | } 115 | ) 116 | }) 117 | 118 | // TODO: see https://github.com/digitalsadhu/loopback-component-jsonapi/issues/11 119 | it('GET /posts should produce top level self links', function (done) { 120 | request(app).get('/posts').expect(200).end(function (err, res) { 121 | expect(err).to.equal(null) 122 | expect(res.body.links.self).to.match(/http:\/\/127\.0\.0\.1.*\/posts$/) 123 | done() 124 | }) 125 | }) 126 | 127 | it('GET /posts/1 should produce resource level self links', function (done) { 128 | request(app).get('/posts/1').expect(200).end(function (err, res) { 129 | expect(err).to.equal(null) 130 | expect(res.body.data.links.self).to.match( 131 | /http:\/\/127\.0\.0\.1.*\/posts\/1$/ 132 | ) 133 | done() 134 | }) 135 | }) 136 | 137 | it( 138 | 'GET /posts/2 should produce correct resource level self links for individual resources', 139 | function (done) { 140 | request(app).get('/posts/2').expect(200).end(function (err, res) { 141 | expect(err).to.equal(null) 142 | expect(res.body.data.links.self).to.match( 143 | /http:\/\/127\.0\.0\.1.*\/posts\/2$/ 144 | ) 145 | done() 146 | }) 147 | } 148 | ) 149 | 150 | it( 151 | 'GET /posts should produce correct resource level self links for collections', 152 | function (done) { 153 | request(app).get('/posts').expect(200).end(function (err, res) { 154 | var index = 1 155 | expect(err).to.equal(null) 156 | expect(res.body.data).to.be.a('array') 157 | expect(res.body.links.self).to.match( 158 | /http:\/\/127\.0\.0\.1.*\/posts$/ 159 | ) 160 | 161 | res.body.data.forEach(function (resource) { 162 | expect(resource.links.self).to.match( 163 | new RegExp('^http://127.0.0.1.*/posts/' + index) 164 | ) 165 | index++ 166 | }) 167 | 168 | // Make sure we have tested all 3 169 | expect(index - 1).to.equal(3) 170 | done() 171 | }) 172 | } 173 | ) 174 | }) 175 | 176 | describe('Empty responses', function () { 177 | it( 178 | 'GET /models should return an empty JSON API resource object when there are no results', 179 | function (done) { 180 | request(app).get('/posts').expect(200).end(function (err, res) { 181 | expect(err).to.equal(null) 182 | expect(res.body).to.be.an('object') 183 | expect(res.body.links).to.be.an('object') 184 | // expect(res.body.links.self).to.match(/^http:\/\/127.0.0.1:.*\/api\/posts/) 185 | expect(res.body.data).to.be.an('array') 186 | expect(res.body.data.length).to.equal(0) 187 | done() 188 | }) 189 | } 190 | ) 191 | 192 | it( 193 | 'GET model/:id should return a general 404 when there are no results', 194 | function (done) { 195 | request(app).get('/posts/1').expect(404).end(done) 196 | } 197 | ) 198 | }) 199 | 200 | describe('Non-empty reponses', function () { 201 | beforeEach(function (done) { 202 | Post.create( 203 | { 204 | title: 'my post', 205 | content: 'my post content' 206 | }, 207 | done 208 | ) 209 | }) 210 | 211 | it('GET /models/ should return a JSON API response with 1 item', function ( 212 | done 213 | ) { 214 | request(app).get('/posts').expect(200).end(function (err, res) { 215 | expect(err).to.equal(null) 216 | expect(res.body).to.have.all.keys('links', 'data') 217 | expect(res.body.links).to.have.all.keys('self') 218 | expect(res.body.data).to.be.an('array') 219 | expect(res.body.data.length).to.equal(1) 220 | expect(res.body.data[0]).to.have.all.keys( 221 | 'id', 222 | 'type', 223 | 'attributes', 224 | 'links' 225 | ) 226 | expect(res.body.data[0].id).to.equal('1') 227 | expect(res.body.data[0].type).to.equal('posts') 228 | expect(res.body.data[0].attributes).to.have.all.keys( 229 | 'title', 230 | 'content' 231 | ) 232 | expect(res.body.data[0].attributes).to.not.have.keys('id') 233 | done() 234 | }) 235 | }) 236 | 237 | it('GET /models/:id should return a correct JSON API response', function ( 238 | done 239 | ) { 240 | request(app).get('/posts/1').expect(200).end(function (err, res) { 241 | expect(err).to.equal(null) 242 | expect(res.body).to.have.all.keys('links', 'data') 243 | expect(res.body.links).to.have.all.keys('self') 244 | expect(res.body.data).to.have.all.keys( 245 | 'id', 246 | 'type', 247 | 'attributes', 248 | 'links' 249 | ) 250 | expect(res.body.data.id).to.equal('1') 251 | expect(res.body.data.type).to.equal('posts') 252 | expect(res.body.data.attributes).to.have.all.keys('title', 'content') 253 | expect(res.body.data.attributes).to.not.have.keys('id') 254 | done() 255 | }) 256 | }) 257 | }) 258 | 259 | describe('Errors', function () { 260 | it('GET /models/doesnt/exist should return a general 404 error', function ( 261 | done 262 | ) { 263 | request(app).get('/posts/doesnt/exist').expect(404).end(done) 264 | }) 265 | }) 266 | 267 | describe('Filtering should still be intact', function () { 268 | beforeEach(function (done) { 269 | Post.create( 270 | { 271 | title: 'deer can jump', 272 | content: 'deer can jump really high in their natural habitat' 273 | }, 274 | function () { 275 | Post.create( 276 | { 277 | title: 'pigs dont fly', 278 | content: "contrary to the myth, pigs don't fly!" 279 | }, 280 | function () { 281 | Post.create( 282 | { 283 | title: 'unicorns come from rainbows', 284 | content: 'at the end of a rainbow may be a pot of gold, but also a mythical unicorn' 285 | }, 286 | done 287 | ) 288 | } 289 | ) 290 | } 291 | ) 292 | }) 293 | 294 | it('should filter only one', function (done) { 295 | request(app) 296 | .get('/posts?filter[where][title]=deer+can+jump') 297 | .expect(200) 298 | .end(function (err, res) { 299 | expect(err).to.equal(null) 300 | expect(res.body.data.length).to.equal(1) 301 | done() 302 | }) 303 | }) 304 | 305 | it('should filter two', function (done) { 306 | request(app) 307 | .get('/posts?filter[where][content][like]=myth') 308 | .expect(200) 309 | .end(function (err, res) { 310 | expect(err).to.equal(null) 311 | expect(res.body.data.length).to.equal(2) 312 | done() 313 | }) 314 | }) 315 | }) 316 | describe('Paging should filter', function () { 317 | beforeEach(function (done) { 318 | Post.create( 319 | { 320 | title: 'deer can jump', 321 | content: 'deer can jump really high in their natural habitat' 322 | }, 323 | function () { 324 | Post.create( 325 | { 326 | title: 'pigs dont fly', 327 | content: "contrary to the myth, pigs don't fly!" 328 | }, 329 | function () { 330 | Post.create( 331 | { 332 | title: 'unicorns come from rainbows', 333 | content: 'at the end of a rainbow may be a pot of gold, but also a mythical unicorn' 334 | }, 335 | done 336 | ) 337 | } 338 | ) 339 | } 340 | ) 341 | }) 342 | 343 | it('should filter only one', function (done) { 344 | request(app) 345 | .get('/posts?page[limit]=1') 346 | .expect(200) 347 | .end(function (err, res) { 348 | expect(err).to.equal(null) 349 | expect(res.body.data.length).to.equal(1) 350 | done() 351 | }) 352 | }) 353 | 354 | it('should filter two', function (done) { 355 | request(app) 356 | .get('/posts?page[limit]=2') 357 | .expect(200) 358 | .end(function (err, res) { 359 | expect(err).to.equal(null) 360 | expect(res.body.data.length).to.equal(2) 361 | done() 362 | }) 363 | }) 364 | 365 | it('should skip first', function (done) { 366 | request(app) 367 | .get('/posts?page[limit]=1&page[offset]=1') 368 | .expect(200) 369 | .end(function (err, res) { 370 | expect(err).to.equal(null) 371 | expect(res.body.data.length).to.equal(1) 372 | expect(res.body.data[0].id).to.equal('2') 373 | done() 374 | }) 375 | }) 376 | }) 377 | }) 378 | 379 | describe('non standard primary key naming', function () { 380 | beforeEach(function (done) { 381 | app = loopback() 382 | app.set('legacyExplorer', false) 383 | var ds = loopback.createDataSource('memory') 384 | Post = ds.createModel('post', { 385 | customId: { type: Number, id: true, generated: true }, 386 | title: String 387 | }) 388 | app.model(Post) 389 | app.use(loopback.rest()) 390 | JSONAPIComponent(app) 391 | Post.create({ title: 'my post' }, done) 392 | }) 393 | 394 | it('should dynamically handle primary key', function (done) { 395 | request(app).get('/posts').expect(200).end(function (err, res) { 396 | expect(err).to.equal(null) 397 | expect(res.body.data.length).to.equal(1) 398 | expect(res.body.data[0].id).to.equal('1') 399 | done() 400 | }) 401 | }) 402 | }) 403 | -------------------------------------------------------------------------------- /test/foreign-keys.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('foreign key configuration', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { title: String }) 18 | app.model(Post) 19 | 20 | Comment = ds.createModel('comment', { comment: String }) 21 | app.model(Comment) 22 | 23 | Comment.belongsTo(Post) 24 | Post.hasMany(Comment) 25 | 26 | app.use(loopback.rest()) 27 | 28 | Post.create({ title: 'my post' }, function (err, post) { 29 | if (err) throw err 30 | post.comments.create({ comment: 'my comment' }, done) 31 | }) 32 | }) 33 | 34 | describe( 35 | 'by default, foreign keys are not exposed through the api', 36 | function () { 37 | beforeEach(function () { 38 | JSONAPIComponent(app) 39 | }) 40 | it('should remove foreign keys from model before output', function (done) { 41 | request(app).get('/comments/1').end(function (err, res) { 42 | expect(err).to.equal(null) 43 | expect(res.body.data.attributes).to.not.include.key('postId') 44 | done() 45 | }) 46 | }) 47 | } 48 | ) 49 | 50 | describe( 51 | 'configuring component to always expose foreign keys through the api', 52 | function () { 53 | beforeEach(function () { 54 | JSONAPIComponent(app, { 55 | foreignKeys: true 56 | }) 57 | }) 58 | it('should not remove foreign keys from models before output', function ( 59 | done 60 | ) { 61 | request(app).get('/comments/1').end(function (err, res) { 62 | expect(err).to.equal(null) 63 | expect(res.body.data.attributes).to.include.key('postId') 64 | done() 65 | }) 66 | }) 67 | } 68 | ) 69 | 70 | describe( 71 | 'configuring component to expose foreign keys for post model through the api', 72 | function () { 73 | beforeEach(function () { 74 | JSONAPIComponent(app, { 75 | foreignKeys: [{ model: 'post' }] 76 | }) 77 | }) 78 | it('should not expose postId on comment model', function (done) { 79 | request(app).get('/comments/1').end(function (err, res) { 80 | expect(err).to.equal(null) 81 | expect(res.body.data.attributes).to.not.include.key('postId') 82 | done() 83 | }) 84 | }) 85 | } 86 | ) 87 | 88 | describe( 89 | 'configuring component to expose foreign keys for comment model through the api', 90 | function () { 91 | beforeEach(function () { 92 | JSONAPIComponent(app, { 93 | foreignKeys: [{ model: 'comment' }] 94 | }) 95 | }) 96 | it('should expose postId on comment model', function (done) { 97 | request(app).get('/comments/1').end(function (err, res) { 98 | expect(err).to.equal(null) 99 | expect(res.body.data.attributes).to.include.key('postId') 100 | done() 101 | }) 102 | }) 103 | } 104 | ) 105 | 106 | describe( 107 | 'configuring component to expose foreign keys for comment model method findById through the api', 108 | function () { 109 | beforeEach(function () { 110 | JSONAPIComponent(app, { 111 | foreignKeys: [{ model: 'comment', method: 'findById' }] 112 | }) 113 | }) 114 | it('should not expose foreign keys in find all', function (done) { 115 | request(app).get('/comments').end(function (err, res) { 116 | expect(err).to.equal(null) 117 | expect(res.body.data[0].attributes).to.not.include.key('postId') 118 | done() 119 | }) 120 | }) 121 | it('should expose foreign keys in find', function (done) { 122 | request(app).get('/comments/1').end(function (err, res) { 123 | expect(err).to.equal(null) 124 | expect(res.body.data.attributes).to.include.key('postId') 125 | done() 126 | }) 127 | }) 128 | } 129 | ) 130 | }) 131 | -------------------------------------------------------------------------------- /test/hasManyPolymorphic.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var ds 9 | var Post 10 | var Resource 11 | 12 | describe('loopback json api hasMany polymorphic relationships', function () { 13 | beforeEach(function () { 14 | app = loopback() 15 | app.set('legacyExplorer', false) 16 | ds = loopback.createDataSource('memory') 17 | 18 | Resource = ds.createModel('resource', { 19 | id: { type: Number, id: true }, 20 | fileName: String, 21 | parentId: Number, 22 | parentType: String 23 | }) 24 | Resource.settings.plural = 'resources' 25 | app.model(Resource) 26 | 27 | Post = ds.createModel('post', { 28 | id: { type: Number, id: true }, 29 | title: String, 30 | content: String 31 | }) 32 | Post.settings.plural = 'posts' 33 | Post.hasMany(Resource, { 34 | as: 'resources', 35 | polymorphic: 'parent' 36 | }) 37 | app.model(Post) 38 | 39 | app.use(loopback.rest()) 40 | JSONAPIComponent(app) 41 | }) 42 | 43 | describe('Post hasMany Resources', function () { 44 | beforeEach(function (done) { 45 | Post.create( 46 | { 47 | title: 'Post One', 48 | content: 'Content' 49 | }, 50 | function (err, post) { 51 | expect(err).to.equal(null) 52 | post.resources.create( 53 | { 54 | fileName: 'blah.jpg', 55 | parentId: post.id, 56 | parentType: 'post' 57 | }, 58 | done 59 | ) 60 | } 61 | ) 62 | }) 63 | 64 | it('should have a relationship to Resources', function (done) { 65 | request(app).get('/posts/1').end(function (err, res) { 66 | expect(err).to.equal(null) 67 | expect(res.body).to.not.have.key('errors') 68 | expect(res.body.data.relationships.resources).to.be.an('object') 69 | done() 70 | }) 71 | }) 72 | 73 | it( 74 | 'should return the Resources that belong to this Post when included flag is present', 75 | function (done) { 76 | request(app).get('/posts/1?include=resources').end(function (err, res) { 77 | expect(err).to.equal(null) 78 | expect(res.body).to.not.have.key('errors') 79 | expect(res.body.included).to.be.an('array') 80 | expect(res.body.included[0].type).to.equal('resources') 81 | expect(res.body.included[0].id).to.equal('1') 82 | done() 83 | }) 84 | } 85 | ) 86 | 87 | it( 88 | 'should return the Resources that belong to this Post when following the relationship link', 89 | function (done) { 90 | request(app).get('/posts/1').end(function (err, res) { 91 | expect(err).to.equal(null) 92 | expect(res.body).to.not.have.key('errors') 93 | const relationships = res.body.data.relationships 94 | const url = relationships.resources.links.related.split('api')[1] 95 | request(app).get(url).end(function (err, res) { 96 | expect(err).to.equal(null) 97 | expect(res.body).to.not.have.key('errors') 98 | expect(res.body.data).to.be.an('array') 99 | expect(res.body.data[0].type).to.equal('resources') 100 | expect(res.body.data[0].id).to.equal('1') 101 | done() 102 | }) 103 | }) 104 | } 105 | ) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/hasManyRelationships.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app, Post, Comment, Author, ds 8 | 9 | describe('loopback json api hasMany relationships', function () { 10 | beforeEach(function () { 11 | app = loopback() 12 | app.set('legacyExplorer', false) 13 | ds = loopback.createDataSource('memory') 14 | 15 | Post = ds.createModel('post', { 16 | title: String, 17 | content: String 18 | }) 19 | app.model(Post) 20 | 21 | Comment = ds.createModel('comment', { 22 | title: String, 23 | comment: String 24 | }) 25 | Comment.settings.plural = 'comments' 26 | app.model(Comment) 27 | 28 | Author = ds.createModel('author', { 29 | firstName: String, 30 | lastName: String 31 | }) 32 | Author.settings.plural = 'authors' 33 | app.model(Author) 34 | 35 | Post.hasMany(Comment) 36 | Comment.belongsTo(Post) 37 | Post.belongsTo(Author) 38 | Author.hasMany(Post) 39 | 40 | app.use(loopback.rest()) 41 | JSONAPIComponent(app, { restApiRoot: '/' }) 42 | }) 43 | 44 | describe('Multiple `includes`', function (done) { 45 | beforeEach(function (done) { 46 | Author.create( 47 | { 48 | firstName: 'Joe', 49 | lastName: 'Shmoe' 50 | }, 51 | function (err, author) { 52 | expect(err).to.equal(null) 53 | author.posts.create( 54 | { 55 | title: 'my post', 56 | content: 'my post content' 57 | }, 58 | function (err, post) { 59 | expect(err).to.equal(null) 60 | post.comments.create( 61 | [ 62 | { 63 | title: 'My comment', 64 | comment: 'My comment text' 65 | }, 66 | { 67 | title: 'My second comment', 68 | comment: 'My second comment text' 69 | } 70 | ], 71 | done 72 | ) 73 | } 74 | ) 75 | } 76 | ) 77 | }) 78 | 79 | it('should sideload author and comments', function (done) { 80 | request(app) 81 | .get('/posts/1/?include=author,comments') 82 | .expect(200) 83 | .end(function (err, res) { 84 | var data = res.body.data 85 | expect(err).to.equal(null) 86 | expect(data.id).to.equal('1') 87 | expect(data.type).to.equal('posts') 88 | expect(data.relationships).to.be.a('object') 89 | expect(data.relationships.author).to.be.a('object') 90 | expect(data.relationships.author.data.id).to.equal('1') 91 | expect(data.relationships.author.data.type).to.equal('authors') 92 | expect(data.relationships.comments.data).to.be.a('array') 93 | expect(data.relationships.comments.data[0].id).to.equal('1') 94 | expect(data.relationships.comments.data[0].type).to.equal('comments') 95 | expect(data.relationships.comments.data[1].id).to.equal('2') 96 | expect(data.relationships.comments.data[1].type).to.equal('comments') 97 | expect(data.attributes).to.deep.equal({ 98 | title: 'my post', 99 | content: 'my post content' 100 | }) 101 | expect(res.body.included).to.be.an('array') 102 | expect(res.body.included.length).to.equal(3) 103 | const relatedPostLink = id => { 104 | return res.body.included[0].relationships.posts.links.related 105 | } 106 | expect(res.body.included[0]).to.deep.equal({ 107 | id: '1', 108 | type: 'authors', 109 | attributes: { 110 | firstName: 'Joe', 111 | lastName: 'Shmoe' 112 | }, 113 | relationships: { 114 | posts: { 115 | links: { 116 | related: relatedPostLink(0) 117 | } 118 | } 119 | } 120 | }) 121 | expect(res.body.included[1]).to.deep.equal({ 122 | id: '1', 123 | type: 'comments', 124 | attributes: { 125 | title: 'My comment', 126 | comment: 'My comment text' 127 | }, 128 | relationships: { 129 | post: { 130 | data: { 131 | id: 1, 132 | type: 'posts' 133 | }, 134 | links: { 135 | related: res.body.included[1].relationships.post.links.related 136 | } 137 | } 138 | } 139 | }) 140 | expect(res.body.included[2]).to.deep.equal({ 141 | id: '2', 142 | type: 'comments', 143 | attributes: { 144 | title: 'My second comment', 145 | comment: 'My second comment text' 146 | }, 147 | relationships: { 148 | post: { 149 | data: { 150 | id: 1, 151 | type: 'posts' 152 | }, 153 | links: { 154 | related: res.body.included[2].relationships.post.links.related 155 | } 156 | } 157 | } 158 | }) 159 | done() 160 | }) 161 | }) 162 | }) 163 | 164 | describe('Duplicate models in `includes`', function () { 165 | beforeEach(function () { 166 | const Foo = ds.createModel('foo', { title: String }) 167 | app.model(Foo) 168 | const Bar = ds.createModel('bar', { title: String }) 169 | app.model(Bar) 170 | Foo.hasMany(Bar) 171 | Foo.belongsTo(Bar) 172 | 173 | return Promise.all([ 174 | Foo.create({ title: 'one', barId: 1 }), 175 | Bar.create({ title: 'one', barId: 1, fooId: 1 }) 176 | ]) 177 | }) 178 | 179 | it('should not occur', function () { 180 | return request(app) 181 | .get('/foos/1/?include=bars,bar') 182 | .expect(200) 183 | .then(function (res) { 184 | expect(res.body.included.length).to.equal( 185 | 1, 186 | 'Should be exactly 1 item in included array' 187 | ) 188 | }) 189 | }) 190 | }) 191 | }) 192 | -------------------------------------------------------------------------------- /test/hasManyThroughUpsert.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global describe, beforeEach, it */ 4 | 5 | var request = require('supertest') 6 | var loopback = require('loopback') 7 | var expect = require('chai').expect 8 | var JSONAPIComponent = require('../') 9 | var RSVP = require('rsvp') 10 | 11 | var app 12 | var Movie, Category, MovieCategory 13 | 14 | describe('hasManyThrough upsert', function () { 15 | beforeEach(function (done) { 16 | app = loopback() 17 | app.set('legacyExplorer', false) 18 | var ds = loopback.createDataSource('memory') 19 | // create models 20 | Movie = ds.createModel('movie', { 21 | id: { type: Number, id: true }, 22 | name: String 23 | }) 24 | 25 | Category = ds.createModel('category', { 26 | id: { type: Number, id: true }, 27 | name: String 28 | }) 29 | 30 | MovieCategory = ds.createModel('movieCategory', { 31 | id: { type: Number, id: true } 32 | }) 33 | 34 | // add models 35 | app.model(Movie) 36 | app.model(Category) 37 | app.model(MovieCategory) 38 | 39 | // set up relationships 40 | Movie.hasMany(Category, { through: MovieCategory }) 41 | Category.hasMany(Movie, { through: MovieCategory }) 42 | 43 | MovieCategory.belongsTo(Movie) 44 | MovieCategory.belongsTo(Category) 45 | makeData() 46 | .then(function () { 47 | done() 48 | }) 49 | .catch(function (err) { 50 | done(err) 51 | }) 52 | app.use(loopback.rest()) 53 | JSONAPIComponent(app, { restApiRoot: '' }) 54 | }) 55 | 56 | it('should make initial data', function (done) { 57 | request(app).get('/movies/1/categories').end(function (err, res) { 58 | expect(err).to.equal(null) 59 | expect(res.body.data.length).to.equal(2) 60 | expect(res.body.data[0].attributes.name).to.equal('Crime') 61 | done(err) 62 | }) 63 | }) 64 | 65 | it('should handle POST', function (done) { 66 | var agent = request(app) 67 | agent 68 | .post('/movies') 69 | .send({ 70 | data: { 71 | type: 'movies', 72 | attributes: { 73 | name: 'Ace Ventura: Pet Detective' 74 | }, 75 | relationships: { 76 | categories: { 77 | data: [{ type: 'categories', id: 4 }] 78 | } 79 | } 80 | } 81 | }) 82 | .end(function () { 83 | agent.get('/movieCategories').end(function (err, res) { 84 | expect(err).to.equal(null) 85 | expect(res.body.data.length).to.equal(3) 86 | done() 87 | }) 88 | }) 89 | }) 90 | 91 | it('should handle PATCH', function (done) { 92 | var agent = request(app) 93 | agent 94 | .patch('/movies/1') 95 | .send({ 96 | data: { 97 | id: 1, 98 | type: 'movies', 99 | attributes: { 100 | name: 'The Shawshank Redemption' 101 | }, 102 | relationships: { 103 | categories: { 104 | data: [ 105 | { type: 'categories', id: 1 }, 106 | { type: 'categories', id: 2 }, 107 | { type: 'categories', id: 3 } 108 | ] 109 | } 110 | } 111 | } 112 | }) 113 | .end(function () { 114 | agent.get('/movieCategories').end(function (err, res) { 115 | expect(err).to.equal(null) 116 | expect(res.body.data.length).to.equal(3) 117 | done() 118 | }) 119 | }) 120 | }) 121 | 122 | it('should handle string IDs', () => { 123 | const agent = request(app) 124 | const payload = { 125 | data: { 126 | id: '1', 127 | type: 'movies', 128 | attributes: { 129 | name: 'The Shawshank Redemption' 130 | }, 131 | relationships: { 132 | categories: { 133 | data: [ 134 | { type: 'categories', id: '1' }, 135 | { type: 'categories', id: '4' } 136 | ] 137 | } 138 | } 139 | } 140 | } 141 | return agent.patch('/movies/1').send(payload).then(() => { 142 | return agent.get('/movies/1/categories').then(res => { 143 | expect(res.body.data.length).to.equal(2) 144 | expect(res.body.data[0].id).to.equal('1') 145 | expect(res.body.data[1].id).to.equal('4') 146 | }) 147 | }) 148 | }) 149 | 150 | it('should handle PATCH with less assocs', function (done) { 151 | var agent = request(app) 152 | agent 153 | .patch('/movies/1') 154 | .send({ 155 | data: { 156 | id: 1, 157 | type: 'movies', 158 | attributes: { 159 | name: 'The Shawshank Redemption' 160 | }, 161 | relationships: { 162 | categories: { 163 | data: [ 164 | { type: 'categories', id: 1 }, 165 | { type: 'categories', id: 4 } 166 | ] 167 | } 168 | } 169 | } 170 | }) 171 | .end(function (err, res) { 172 | expect(err).to.equal(null) 173 | agent.get('/movieCategories').end(function (err, res) { 174 | expect(err).to.equal(null) 175 | expect(res.body.data.length).to.equal(2) 176 | 177 | agent.get('/movies/1/categories').end(function (err, res) { 178 | expect(err).to.equal(null) 179 | expect(res.body.data.length).to.equal(2) 180 | expect(res.body.data[1].attributes.name).to.equal('Comedy') 181 | done() 182 | }) 183 | }) 184 | }) 185 | }) 186 | }) 187 | 188 | function makeData () { 189 | var createMovie = denodeifyCreate(Movie) 190 | var createCategory = denodeifyCreate(Category) 191 | var createAssoc = denodeifyCreate(MovieCategory) 192 | 193 | return RSVP.hash({ 194 | movie: createMovie({ name: 'The Shawshank Redemption' }), 195 | categories: RSVP.all([ 196 | createCategory({ name: 'Crime' }), 197 | createCategory({ name: 'Drama' }), 198 | createCategory({ name: 'History' }), 199 | createCategory({ name: 'Comedy' }) 200 | ]) 201 | }) 202 | .then(function (models) { 203 | return RSVP.all([ 204 | createAssoc({ 205 | movieId: models.movie.id, 206 | categoryId: models.categories[0].id 207 | }), 208 | createAssoc({ 209 | movieId: models.movie.id, 210 | categoryId: models.categories[2].id 211 | }) 212 | ]) 213 | }) 214 | 215 | function denodeifyCreate (Model) { 216 | return RSVP.denodeify(Model.create.bind(Model)) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /test/hasManyThroughUpsertStringIds.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global describe, beforeEach, it */ 4 | 5 | var request = require('supertest') 6 | var loopback = require('loopback') 7 | var expect = require('chai').expect 8 | var JSONAPIComponent = require('../') 9 | var RSVP = require('rsvp') 10 | 11 | var app 12 | var Movie, Category, MovieCategory 13 | 14 | describe('hasManyThrough upsert', function () { 15 | beforeEach(function (done) { 16 | app = loopback() 17 | app.set('legacyExplorer', false) 18 | var ds = loopback.createDataSource('memory') 19 | // create models 20 | Movie = ds.createModel('movie', { 21 | id: { type: String, id: true }, 22 | name: String 23 | }) 24 | 25 | Category = ds.createModel('category', { 26 | id: { type: String, id: true }, 27 | name: String 28 | }) 29 | 30 | MovieCategory = ds.createModel('movieCategory', { 31 | id: { type: Number, id: true } 32 | }) 33 | 34 | // add models 35 | app.model(Movie) 36 | app.model(Category) 37 | app.model(MovieCategory) 38 | 39 | // set up relationships 40 | Movie.hasMany(Category, { through: MovieCategory }) 41 | Category.hasMany(Movie, { through: MovieCategory }) 42 | 43 | MovieCategory.belongsTo(Movie) 44 | MovieCategory.belongsTo(Category) 45 | makeData() 46 | .then(function () { 47 | done() 48 | }) 49 | .catch(function (err) { 50 | done(err) 51 | }) 52 | app.use(loopback.rest()) 53 | JSONAPIComponent(app, { restApiRoot: '' }) 54 | }) 55 | 56 | it('should make initial data', function (done) { 57 | request(app).get('/movies/M1/categories').end(function (err, res) { 58 | expect(err).to.equal(null) 59 | expect(res.body.data.length).to.equal(2) 60 | expect(res.body.data[0].attributes.name).to.equal('Crime') 61 | done(err) 62 | }) 63 | }) 64 | 65 | it('should handle PATCH', function (done) { 66 | var agent = request(app) 67 | agent 68 | .patch('/movies/M1') 69 | .send({ 70 | data: { 71 | id: 1, 72 | type: 'movies', 73 | attributes: { 74 | name: 'The Shawshank Redemption' 75 | }, 76 | relationships: { 77 | categories: { 78 | data: [ 79 | { type: 'categories', id: 'C1' }, 80 | { type: 'categories', id: 'C2' }, 81 | { type: 'categories', id: 'C3' } 82 | ] 83 | } 84 | } 85 | } 86 | }) 87 | .end(function () { 88 | agent.get('/movieCategories').end(function (err, res) { 89 | expect(err).to.equal(null) 90 | expect(res.body.data.length).to.equal(3) 91 | done() 92 | }) 93 | }) 94 | }) 95 | }) 96 | 97 | function makeData () { 98 | var createMovie = denodeifyCreate(Movie) 99 | var createCategory = denodeifyCreate(Category) 100 | var createAssoc = denodeifyCreate(MovieCategory) 101 | 102 | return RSVP.hash({ 103 | movie: createMovie({ id: 'M1', name: 'The Shawshank Redemption' }), 104 | categories: RSVP.all([ 105 | createCategory({ id: 'C1', name: 'Crime' }), 106 | createCategory({ id: 'C2', name: 'Drama' }), 107 | createCategory({ id: 'C3', name: 'History' }), 108 | createCategory({ id: 'C4', name: 'Comedy' }) 109 | ]) 110 | }) 111 | .then(function (models) { 112 | return RSVP.all([ 113 | createAssoc({ 114 | movieId: models.movie.id, 115 | categoryId: models.categories[0].id 116 | }), 117 | createAssoc({ 118 | movieId: models.movie.id, 119 | categoryId: models.categories[2].id 120 | }) 121 | ]) 122 | }) 123 | 124 | function denodeifyCreate (Model) { 125 | return RSVP.denodeify(Model.create.bind(Model)) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/hasOne.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Person 10 | var ds 11 | 12 | describe('loopback json api hasOne relationships', function () { 13 | beforeEach(function () { 14 | app = loopback() 15 | app.set('legacyExplorer', false) 16 | ds = loopback.createDataSource('memory') 17 | Post = ds.createModel('post', { 18 | id: { type: Number, id: true }, 19 | title: String, 20 | content: String 21 | }) 22 | app.model(Post) 23 | 24 | Person = ds.createModel('person', { 25 | id: { type: Number, id: true }, 26 | postId: Number, 27 | name: String 28 | }) 29 | app.model(Person) 30 | Post.hasOne(Person, { as: 'author', foreignKey: 'postId' }) 31 | Person.settings.plural = 'people' 32 | 33 | app.use(loopback.rest()) 34 | JSONAPIComponent(app) 35 | }) 36 | 37 | describe('Post without an author', function (done) { 38 | beforeEach(function (done) { 39 | Post.create( 40 | { 41 | name: 'my post', 42 | content: 'my post content' 43 | }, 44 | done 45 | ) 46 | }) 47 | 48 | describe('GET /posts/1/author', function () { 49 | it('should return status code 200 OK', function (done) { 50 | request(app).get('/posts/1/author').expect(200).end(done) 51 | }) 52 | 53 | it('should return `null` keyed by `data`', function (done) { 54 | request(app).get('/posts/1/author').end(function (err, res) { 55 | expect(err).to.equal(null) 56 | expect(res.body).to.be.an('object') 57 | expect(res.body.links).to.be.an('object') 58 | expect(res.body.links.self).to.match(/posts\/1\/author/) 59 | expect(res.body.data).to.equal(null) 60 | done() 61 | }) 62 | }) 63 | }) 64 | 65 | describe('GET /posts/1/relationships/author', function () { 66 | it('should return status code 200 OK', function (done) { 67 | request(app).get('/posts/1/relationships/author').expect(200).end(done) 68 | }) 69 | 70 | it('should return `null` keyed by `data`', function (done) { 71 | request(app) 72 | .get('/posts/1/relationships/author') 73 | .end(function (err, res) { 74 | expect(err).to.equal(null) 75 | expect(res.body).to.be.an('object') 76 | expect(res.body.links).to.be.an('object') 77 | expect(res.body.links.self).to.match( 78 | /posts\/1\/relationships\/author/ 79 | ) 80 | expect(res.body.links.related).to.match(/posts\/1\/author/) 81 | expect(res.body.data).to.equal(null) 82 | done() 83 | }) 84 | }) 85 | }) 86 | }) 87 | 88 | describe('Post with an author', function (done) { 89 | beforeEach(function (done) { 90 | Post.create( 91 | { 92 | name: 'my post', 93 | content: 'my post content' 94 | }, 95 | function (err, post) { 96 | expect(err).to.equal(null) 97 | post.author.create( 98 | { 99 | name: 'Bob Jones' 100 | }, 101 | done 102 | ) 103 | } 104 | ) 105 | }) 106 | 107 | describe('GET /posts/1/author', function () { 108 | it('should return status code 200 OK', function (done) { 109 | request(app).get('/posts/1/author').expect(200).end(done) 110 | }) 111 | 112 | it('should display a single resource object keyed by `data`', function ( 113 | done 114 | ) { 115 | request(app).get('/posts/1/author').end(function (err, res) { 116 | expect(err).to.equal(null) 117 | expect(res.body).to.be.an('object') 118 | expect(res.body.links).to.be.an('object') 119 | expect(res.body.links.self).to.match(/posts\/1\/author/) 120 | expect(res.body.data.attributes).to.deep.equal({ 121 | name: 'Bob Jones' 122 | }) 123 | expect(res.body.data.type).to.equal('people') 124 | expect(res.body.data.id).to.equal('1') 125 | expect(res.body.data.links.self).to.match(/^http.*\/people\/1$/) 126 | done() 127 | }) 128 | }) 129 | }) 130 | 131 | describe('GET /posts/1/relationships/author', function () { 132 | it('should return status code 200 OK', function (done) { 133 | request(app).get('/posts/1/relationships/author').expect(200).end(done) 134 | }) 135 | 136 | it('should display a single resource object keyed by `data`', function ( 137 | done 138 | ) { 139 | request(app) 140 | .get('/posts/1/relationships/author') 141 | .end(function (err, res) { 142 | expect(err).to.equal(null) 143 | expect(res.body).to.be.an('object') 144 | expect(res.body.links).to.be.an('object') 145 | expect(res.body.links.self).to.match( 146 | /posts\/1\/relationships\/author/ 147 | ) 148 | expect(res.body.links.related).to.match(/posts\/1\/author/) 149 | expect(res.body.data).to.deep.equal({ 150 | type: 'people', 151 | id: '1' 152 | }) 153 | done() 154 | }) 155 | }) 156 | }) 157 | 158 | describe( 159 | 'embedded relationship information in collections (GET /:collection)', 160 | function () { 161 | it( 162 | 'should return author relationship link in relationships object', 163 | function (done) { 164 | request(app).get('/posts').end(function (err, res) { 165 | expect(err).to.equal(null) 166 | expect(res.body.data[0].relationships).to.be.an('object') 167 | expect(res.body.data[0].relationships.author).to.be.an('object') 168 | expect(res.body.data[0].relationships.author.links).to.be.an( 169 | 'object' 170 | ) 171 | expect( 172 | res.body.data[0].relationships.author.links.related 173 | ).to.match(/posts\/1\/author/) 174 | done() 175 | }) 176 | } 177 | ) 178 | 179 | it( 180 | 'should not include data key relationships object if `include` is not specified', 181 | function (done) { 182 | request(app).get('/posts').end(function (err, res) { 183 | expect(err).to.equal(null) 184 | expect(res.body.data[0].relationships.author).not.to.have.key( 185 | 'data' 186 | ) 187 | done() 188 | }) 189 | } 190 | ) 191 | 192 | it( 193 | 'should return included data as a compound document using key "included"', 194 | function (done) { 195 | request(app) 196 | .get('/posts?filter[include]=author') 197 | .end(function (err, res) { 198 | expect(err).to.equal(null) 199 | expect(res.body.data[0].relationships).to.be.an('object') 200 | expect(res.body.data[0].relationships.author).to.be.an( 201 | 'object' 202 | ) 203 | expect( 204 | res.body.data[0].relationships.author.data 205 | ).to.deep.equal({ 206 | type: 'people', 207 | id: '1' 208 | }) 209 | expect(res.body.data[0].relationships.author.links).to.be.an( 210 | 'object' 211 | ) 212 | expect( 213 | res.body.data[0].relationships.author.links.related 214 | ).to.match(/posts\/1\/author/) 215 | expect(res.body.included).to.be.an('array') 216 | expect(res.body.included.length).to.equal(1) 217 | expect(res.body.included[0]).to.have.all.keys( 218 | 'type', 219 | 'id', 220 | 'attributes' 221 | ) 222 | expect(res.body.included[0].type).to.equal('people') 223 | expect(res.body.included[0].id).to.equal('1') 224 | done() 225 | }) 226 | } 227 | ) 228 | 229 | it( 230 | 'should return a 400 Bad Request error if a non existent relationship is specified.', 231 | function (done) { 232 | request(app) 233 | .get('/posts?filter[include]=doesnotexist') 234 | .expect(400) 235 | .end(done) 236 | } 237 | ) 238 | 239 | it( 240 | 'should allow specifying `include` in the url to meet JSON API spec. eg. include=author', 241 | function (done) { 242 | request(app).get('/posts?include=author').end(function (err, res) { 243 | expect(err).to.equal(null) 244 | expect(res.body.included).to.be.an('array') 245 | expect(res.body.included.length).to.equal(1) 246 | expect(res.body.included[0]).to.deep.equal({ 247 | id: '1', 248 | type: 'people', 249 | attributes: { 250 | postId: 1, 251 | name: 'Bob Jones' 252 | } 253 | }) 254 | done() 255 | }) 256 | } 257 | ) 258 | 259 | it( 260 | 'should return a 400 Bad Request error if a non existent relationship is specified using JSON API syntax.', 261 | function (done) { 262 | request(app) 263 | .get('/posts?include=doesnotexist') 264 | .expect(400) 265 | .end(done) 266 | } 267 | ) 268 | } 269 | ) 270 | 271 | describe( 272 | 'embedded relationship information for individual resource GET /:collection/:id', 273 | function () { 274 | it( 275 | 'should return author relationship link in relationships object', 276 | function (done) { 277 | request(app).get('/posts/1').end(function (err, res) { 278 | expect(err).to.equal(null) 279 | expect(res.body.data.relationships).to.be.an('object') 280 | expect(res.body.data.relationships.author).to.be.an('object') 281 | expect(res.body.data.relationships.author.links).to.be.an( 282 | 'object' 283 | ) 284 | expect(res.body.data.relationships.author.links.related).to.match( 285 | /posts\/1\/author/ 286 | ) 287 | done() 288 | }) 289 | } 290 | ) 291 | 292 | it( 293 | 'should not include data key relationships object if `include` is not specified', 294 | function (done) { 295 | request(app).get('/posts/1').end(function (err, res) { 296 | expect(err).to.equal(null) 297 | expect(res.body.data.relationships.author).not.to.have.key( 298 | 'data' 299 | ) 300 | done() 301 | }) 302 | } 303 | ) 304 | 305 | it( 306 | 'should return included data as a compound document using key "included"', 307 | function (done) { 308 | request(app) 309 | .get('/posts/1?filter[include]=author') 310 | .end(function (err, res) { 311 | expect(err).to.equal(null) 312 | expect(res.body.data.relationships).to.be.an('object') 313 | expect(res.body.data.relationships.author).to.be.an('object') 314 | expect(res.body.data.relationships.author.data).to.deep.equal({ 315 | type: 'people', 316 | id: '1' 317 | }) 318 | expect(res.body.data.relationships.author.links).to.be.an( 319 | 'object' 320 | ) 321 | expect( 322 | res.body.data.relationships.author.links.related 323 | ).to.match(/posts\/1\/author/) 324 | expect(res.body.included).to.be.an('array') 325 | expect(res.body.included.length).to.equal(1) 326 | expect(res.body.included[0]).to.have.all.keys( 327 | 'type', 328 | 'id', 329 | 'attributes' 330 | ) 331 | expect(res.body.included[0].type).to.equal('people') 332 | expect(res.body.included[0].id).to.equal('1') 333 | done() 334 | }) 335 | } 336 | ) 337 | 338 | it( 339 | 'should return a 400 Bad Request error if a non existent relationship is specified.', 340 | function (done) { 341 | request(app) 342 | .get('/posts/1?filter[include]=doesnotexist') 343 | .expect(400) 344 | .end(done) 345 | } 346 | ) 347 | 348 | it( 349 | 'should allow specifying `include` in the url to meet JSON API spec. eg. include=author', 350 | function (done) { 351 | request(app).get('/posts/1?include=author').end(function (err, res) { 352 | expect(err).to.equal(null) 353 | expect(res.body.included).to.be.an('array') 354 | expect(res.body.included.length).to.equal(1) 355 | done() 356 | }) 357 | } 358 | ) 359 | 360 | it( 361 | 'should return a 400 Bad Request error if a non existent relationship is specified using JSON API syntax.', 362 | function (done) { 363 | request(app) 364 | .get('/posts/1?include=doesnotexist') 365 | .expect(400) 366 | .end(done) 367 | } 368 | ) 369 | } 370 | ) 371 | }) 372 | }) 373 | -------------------------------------------------------------------------------- /test/include.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('include option', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { title: String }) 18 | app.model(Post) 19 | Post.customMethod = function (cb) { 20 | cb(null, { prop: 'value', id: null }) 21 | } 22 | Post.remoteMethod('customMethod', { 23 | http: { verb: 'get', path: '/custom' }, 24 | returns: { root: true } 25 | }) 26 | Post.customMethod2 = function (cb) { 27 | cb(null, { prop: 'value', id: null }) 28 | } 29 | Post.remoteMethod('customMethod2', { 30 | http: { verb: 'get', path: '/custom2' }, 31 | returns: { root: true } 32 | }) 33 | 34 | Comment = ds.createModel('comment', { comment: String }) 35 | app.model(Comment) 36 | Comment.customMethod = function (cb) { 37 | cb(null, { prop: 'value', id: null }) 38 | } 39 | Comment.remoteMethod('customMethod', { 40 | http: { verb: 'get', path: '/custom' }, 41 | returns: { root: true } 42 | }) 43 | 44 | app.use(loopback.rest()) 45 | 46 | Post.create({ title: 'my post' }, function () { 47 | Comment.create({ comment: 'my comment' }, done) 48 | }) 49 | }) 50 | 51 | describe('including a specific method', function () { 52 | beforeEach(function () { 53 | JSONAPIComponent(app, { 54 | include: [{ methods: 'customMethod' }] 55 | }) 56 | }) 57 | 58 | it( 59 | 'should apply jsonapi to post model output for customMethod method', 60 | function (done) { 61 | request(app).get('/posts/custom').expect(200).end(function (err, res) { 62 | expect(err).to.equal(null) 63 | expect(res.body.data).to.have.keys( 64 | 'id', 65 | 'type', 66 | 'attributes', 67 | 'links' 68 | ) 69 | done() 70 | }) 71 | } 72 | ) 73 | 74 | it( 75 | 'should apply jsonapi to comment model output for customMethod method', 76 | function (done) { 77 | request(app) 78 | .get('/comments/custom') 79 | .expect(200) 80 | .end(function (err, res) { 81 | expect(err).to.equal(null) 82 | expect(res.body.data).to.have.keys( 83 | 'id', 84 | 'type', 85 | 'attributes', 86 | 'links' 87 | ) 88 | done() 89 | }) 90 | } 91 | ) 92 | }) 93 | 94 | describe('including a specific method on a specific model', function () { 95 | beforeEach(function () { 96 | JSONAPIComponent(app, { 97 | include: [{ model: 'post', methods: 'customMethod' }] 98 | }) 99 | }) 100 | 101 | it( 102 | 'should apply jsonapi to post model output for customMethod method', 103 | function (done) { 104 | request(app).get('/posts/custom').expect(200).end(function (err, res) { 105 | expect(err).to.equal(null) 106 | expect(res.body.data).to.have.keys( 107 | 'id', 108 | 'type', 109 | 'attributes', 110 | 'links' 111 | ) 112 | done() 113 | }) 114 | } 115 | ) 116 | 117 | it( 118 | 'should not apply jsonapi to comment model output for customMethod method', 119 | function (done) { 120 | request(app) 121 | .get('/comments/custom') 122 | .expect(200) 123 | .end(function (err, res) { 124 | expect(err).to.equal(null) 125 | expect(res.body).to.deep.equal({ prop: 'value', id: null }) 126 | done() 127 | }) 128 | } 129 | ) 130 | }) 131 | 132 | describe( 133 | 'including a specific set of methods on a specific model', 134 | function () { 135 | beforeEach(function () { 136 | JSONAPIComponent(app, { 137 | include: [ 138 | { model: 'post', methods: ['customMethod', 'customMethod2'] } 139 | ] 140 | }) 141 | }) 142 | 143 | it( 144 | 'should apply jsonapi to post model output for customMethod method', 145 | function (done) { 146 | request(app).get('/posts/custom').expect(200).end(function (err, res) { 147 | expect(err).to.equal(null) 148 | expect(res.body.data).to.have.keys( 149 | 'id', 150 | 'type', 151 | 'attributes', 152 | 'links' 153 | ) 154 | done() 155 | }) 156 | } 157 | ) 158 | 159 | it( 160 | 'should apply jsonapi to post model output for customMethod method', 161 | function (done) { 162 | request(app) 163 | .get('/posts/custom2') 164 | .expect(200) 165 | .end(function (err, res) { 166 | expect(err).to.equal(null) 167 | expect(res.body.data).to.have.keys( 168 | 'id', 169 | 'type', 170 | 'attributes', 171 | 'links' 172 | ) 173 | done() 174 | }) 175 | } 176 | ) 177 | } 178 | ) 179 | }) 180 | -------------------------------------------------------------------------------- /test/override-attributes.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('attributes option', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { 18 | title: String, 19 | content: String, 20 | other: String 21 | }) 22 | app.model(Post) 23 | 24 | Comment = ds.createModel('comment', { 25 | title: String, 26 | comment: String, 27 | other: String 28 | }) 29 | app.model(Comment) 30 | 31 | app.use(loopback.rest()) 32 | 33 | Post.create( 34 | { title: 'my post', content: 'my post content', other: 'my post other' }, 35 | function () { 36 | Comment.create( 37 | { 38 | title: 'my comment title', 39 | comment: 'my comment', 40 | other: 'comment other' 41 | }, 42 | done 43 | ) 44 | } 45 | ) 46 | }) 47 | 48 | describe('whitelisting model attributes', function () { 49 | beforeEach(function () { 50 | JSONAPIComponent(app, { 51 | attributes: { 52 | posts: ['title'] 53 | } 54 | }) 55 | }) 56 | 57 | it('should return only title in attributes for posts', function (done) { 58 | request(app).get('/posts').expect(200).end(function (err, res) { 59 | expect(err).to.equal(null) 60 | expect(res.body.data[0].attributes).to.deep.equal({ title: 'my post' }) 61 | done() 62 | }) 63 | }) 64 | 65 | it('should return all attributes for comments', function (done) { 66 | request(app).get('/comments').expect(200).end(function (err, res) { 67 | expect(err).to.equal(null) 68 | expect(res.body.data[0].attributes).to.deep.equal({ 69 | title: 'my comment title', 70 | comment: 'my comment', 71 | other: 'comment other' 72 | }) 73 | done() 74 | }) 75 | }) 76 | 77 | it('should return only title in attributes for a post', function (done) { 78 | request(app).get('/posts/1').expect(200).end(function (err, res) { 79 | expect(err).to.equal(null) 80 | expect(res.body.data.attributes).to.deep.equal({ title: 'my post' }) 81 | done() 82 | }) 83 | }) 84 | 85 | it('should return all attributes for a comment', function (done) { 86 | request(app).get('/comments/1').expect(200).end(function (err, res) { 87 | expect(err).to.equal(null) 88 | expect(res.body.data.attributes).to.deep.equal({ 89 | title: 'my comment title', 90 | comment: 'my comment', 91 | other: 'comment other' 92 | }) 93 | done() 94 | }) 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/override-deserializer.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('hook in to modify deserialization process', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { 18 | title: String, 19 | content: String, 20 | other: String 21 | }) 22 | app.model(Post) 23 | 24 | Comment = ds.createModel('comment', { 25 | title: String, 26 | comment: String, 27 | other: String 28 | }) 29 | app.model(Comment) 30 | 31 | Comment.belongsTo(Post) 32 | Post.hasMany(Comment) 33 | 34 | app.use(loopback.rest()) 35 | 36 | Post.create( 37 | { title: 'my post', content: 'my post content', other: 'my post other' }, 38 | function () { 39 | Comment.create( 40 | { 41 | title: 'my comment title', 42 | comment: 'my comment', 43 | other: 'comment other' 44 | }, 45 | done 46 | ) 47 | } 48 | ) 49 | 50 | JSONAPIComponent(app) 51 | }) 52 | 53 | describe('before deserialization', function () { 54 | beforeEach(function () { 55 | Post.beforeJsonApiDeserialize = function (options, cb) { 56 | options.data.data.attributes.title = 'my post 2' 57 | cb(null, options) 58 | } 59 | }) 60 | afterEach(function () { 61 | delete Post.beforeJsonApiDeserialize 62 | }) 63 | it('should allow options to be modified before serialization', function ( 64 | done 65 | ) { 66 | request(app) 67 | .post('/posts') 68 | .send({ 69 | data: { 70 | type: 'posts', 71 | attributes: { 72 | title: 'my post', 73 | content: 'my post content' 74 | } 75 | } 76 | }) 77 | .set('Accept', 'application/vnd.api+json') 78 | .set('Content-Type', 'application/json') 79 | .expect(201) 80 | .end(function (err, res) { 81 | expect(err).to.equal(null) 82 | expect(res.body.data.attributes.title).to.equal('my post 2') 83 | done() 84 | }) 85 | }) 86 | it( 87 | 'should not affect models without a beforeJsonApiDeserialize method', 88 | function (done) { 89 | request(app) 90 | .post('/comments') 91 | .send({ 92 | data: { 93 | type: 'comments', 94 | attributes: { 95 | title: 'my comment title', 96 | comment: 'my comment content' 97 | } 98 | } 99 | }) 100 | .expect(201) 101 | .end(function (err, res) { 102 | expect(err).to.equal(null) 103 | expect(res.body.data.attributes.title).to.equal('my comment title') 104 | done() 105 | }) 106 | } 107 | ) 108 | }) 109 | 110 | describe('before deserialization manipulating relationships', function () { 111 | beforeEach(function () { 112 | Post.beforeJsonApiDeserialize = function (options, cb) { 113 | options.data.data.relationships = { 114 | comments: { 115 | data: [ 116 | { 117 | type: 'comments', 118 | id: '1' 119 | } 120 | ] 121 | } 122 | } 123 | cb(null, options) 124 | } 125 | }) 126 | afterEach(function () { 127 | delete Post.beforeJsonApiDeserialize 128 | }) 129 | it( 130 | 'should save relationships manipulated in beforeJsonApiDeserialize', 131 | function (done) { 132 | request(app) 133 | .post('/posts') 134 | .send({ 135 | data: { 136 | type: 'posts', 137 | attributes: { 138 | title: 'my post', 139 | content: 'my post content' 140 | }, 141 | relationships: { fake: {} } 142 | } 143 | }) 144 | .set('Accept', 'application/vnd.api+json') 145 | .set('Content-Type', 'application/json') 146 | .expect(201) 147 | .end(function (err, res) { 148 | expect(err).to.equal(null) 149 | Comment.findById(1, function (err, comment) { 150 | expect(err).to.equal(null) 151 | expect(comment.postId).to.equal(2) 152 | done() 153 | }) 154 | }) 155 | } 156 | ) 157 | }) 158 | 159 | describe('after deserialization', function () { 160 | beforeEach(function () { 161 | Post.afterJsonApiDeserialize = function (options, cb) { 162 | options.result.title = 'my post 3' 163 | cb(null, options) 164 | } 165 | }) 166 | afterEach(function () { 167 | delete Post.afterJsonApiDeserialize 168 | }) 169 | it('should allow options to be modified after serialization', function ( 170 | done 171 | ) { 172 | request(app) 173 | .post('/posts') 174 | .send({ 175 | data: { 176 | type: 'posts', 177 | attributes: { 178 | title: 'my post', 179 | content: 'my post content' 180 | } 181 | } 182 | }) 183 | .set('Accept', 'application/vnd.api+json') 184 | .set('Content-Type', 'application/json') 185 | .expect(201) 186 | .end(function (err, res) { 187 | expect(err).to.equal(null) 188 | expect(res.body.data.attributes.title).to.equal('my post 3') 189 | done() 190 | }) 191 | }) 192 | it( 193 | 'should not affect models without a afterJsonApiDeserialize method', 194 | function (done) { 195 | request(app) 196 | .post('/comments') 197 | .send({ 198 | data: { 199 | type: 'comments', 200 | attributes: { 201 | title: 'my comment title', 202 | comment: 'my comment content' 203 | } 204 | } 205 | }) 206 | .expect(201) 207 | .end(function (err, res) { 208 | expect(err).to.equal(null) 209 | expect(res.body.data.attributes.title).to.equal('my comment title') 210 | done() 211 | }) 212 | } 213 | ) 214 | }) 215 | 216 | describe('custom deserialization', function () { 217 | beforeEach(function () { 218 | Post.jsonApiDeserialize = function (options, cb) { 219 | options.result = options.data.data.attributes 220 | options.result.title = 'super dooper' 221 | cb(null, options) 222 | } 223 | }) 224 | afterEach(function () { 225 | delete Post.jsonApiDeserialize 226 | }) 227 | it('should allow custom deserialization', function (done) { 228 | request(app) 229 | .post('/posts') 230 | .send({ 231 | data: { 232 | type: 'posts', 233 | attributes: { 234 | title: 'my post', 235 | content: 'my post content' 236 | } 237 | } 238 | }) 239 | .set('Accept', 'application/vnd.api+json') 240 | .set('Content-Type', 'application/json') 241 | .expect(201) 242 | .end(function (err, res) { 243 | expect(err).to.equal(null) 244 | expect(res.body.data.attributes.title).to.equal('super dooper') 245 | done() 246 | }) 247 | }) 248 | it('should not affect models without a jsonApiDeserialize method', function ( 249 | done 250 | ) { 251 | request(app) 252 | .post('/comments') 253 | .send({ 254 | data: { 255 | type: 'comments', 256 | attributes: { 257 | title: 'my comment title', 258 | comment: 'my comment content' 259 | } 260 | } 261 | }) 262 | .expect(201) 263 | .end(function (err, res) { 264 | expect(err).to.equal(null) 265 | expect(res.body.data.attributes.title).to.equal('my comment title') 266 | done() 267 | }) 268 | }) 269 | }) 270 | }) 271 | -------------------------------------------------------------------------------- /test/override-serializer.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | var Comment 10 | 11 | describe('hook in to modify serialization process', function () { 12 | beforeEach(function (done) { 13 | app = loopback() 14 | app.set('legacyExplorer', false) 15 | var ds = loopback.createDataSource('memory') 16 | 17 | Post = ds.createModel('post', { 18 | title: String, 19 | content: String, 20 | other: String 21 | }) 22 | app.model(Post) 23 | 24 | Comment = ds.createModel('comment', { 25 | title: String, 26 | comment: String, 27 | other: String 28 | }) 29 | app.model(Comment) 30 | 31 | app.use(loopback.rest()) 32 | 33 | Post.create( 34 | { title: 'my post', content: 'my post content', other: 'my post other' }, 35 | function () { 36 | Comment.create( 37 | { 38 | title: 'my comment title', 39 | comment: 'my comment', 40 | other: 'comment other' 41 | }, 42 | done 43 | ) 44 | } 45 | ) 46 | 47 | JSONAPIComponent(app) 48 | }) 49 | 50 | describe('before serialization', function () { 51 | beforeEach(function () { 52 | Post.beforeJsonApiSerialize = function (options, cb) { 53 | options.type = 'notPosts' 54 | cb(null, options) 55 | } 56 | }) 57 | afterEach(function () { 58 | delete Post.beforeJsonApiSerialize 59 | }) 60 | it('should allow options to be modified before serialization', function ( 61 | done 62 | ) { 63 | request(app).get('/posts').expect(200).end(function (err, res) { 64 | expect(err).to.equal(null) 65 | expect(res.body.data[0].type).to.equal('notPosts') 66 | done() 67 | }) 68 | }) 69 | it( 70 | 'should not affect models without a beforeJsonApiSerialize method', 71 | function (done) { 72 | request(app).get('/comments').expect(200).end(function (err, res) { 73 | expect(err).to.equal(null) 74 | expect(res.body.data[0].type).to.equal('comments') 75 | done() 76 | }) 77 | } 78 | ) 79 | }) 80 | 81 | describe('after serialization', function () { 82 | beforeEach(function () { 83 | Post.afterJsonApiSerialize = function (options, cb) { 84 | options.results.data = options.results.data.map(function (result) { 85 | result.attributes.testing = true 86 | return result 87 | }) 88 | cb(null, options) 89 | } 90 | }) 91 | afterEach(function () { 92 | delete Post.afterJsonApiSerialize 93 | }) 94 | it('should allow results to be modified after serialization', function ( 95 | done 96 | ) { 97 | request(app).get('/posts').expect(200).end(function (err, res) { 98 | expect(err).to.equal(null) 99 | expect(res.body.data[0].attributes.testing).to.equal(true) 100 | done() 101 | }) 102 | }) 103 | it( 104 | 'should not affect models without an afterJsonApiSerialize method', 105 | function (done) { 106 | request(app).get('/comments').expect(200).end(function (err, res) { 107 | expect(err).to.equal(null) 108 | expect(res.body.data[0].attributes.testing).to.equal(undefined) 109 | done() 110 | }) 111 | } 112 | ) 113 | }) 114 | 115 | describe('custom serialization', function () { 116 | beforeEach(function () { 117 | Post.jsonApiSerialize = function (options, cb) { 118 | options.results = options.results.map(function (result) { 119 | return result.title 120 | }) 121 | cb(null, options) 122 | } 123 | }) 124 | afterEach(function () { 125 | delete Post.jsonApiSerialize 126 | }) 127 | it('should allow a custom serializer to be defined for a model', function ( 128 | done 129 | ) { 130 | request(app).get('/posts').expect(200).end(function (err, res) { 131 | expect(err).to.equal(null) 132 | expect(res.body[0]).to.equal('my post') 133 | done() 134 | }) 135 | }) 136 | it('should not affect models without a jsonApiSerialize method', function ( 137 | done 138 | ) { 139 | request(app).get('/comments').expect(200).end(function (err, res) { 140 | expect(err).to.equal(null) 141 | expect(res.body.data[0].attributes.title).to.equal('my comment title') 142 | done() 143 | }) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/referencesMany.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app, Post, Comment, ds 8 | 9 | describe('loopback json api referencesMany relationships', function () { 10 | beforeEach(function () { 11 | app = loopback() 12 | app.set('legacyExplorer', false) 13 | ds = loopback.createDataSource('memory') 14 | Post = ds.createModel('post', { 15 | id: { type: Number, id: true }, 16 | title: String, 17 | content: String 18 | }) 19 | app.model(Post) 20 | 21 | Comment = ds.createModel('comment', { 22 | id: { type: Number, id: true }, 23 | title: String, 24 | comment: String 25 | }) 26 | app.model(Comment) 27 | Post.referencesMany(Comment, { 28 | property: 'comments', 29 | foreignKey: 'commentIDs' 30 | }) 31 | Comment.settings.plural = 'comments' 32 | 33 | app.use(loopback.rest()) 34 | JSONAPIComponent(app, { restApiRoot: '/' }) 35 | }) 36 | 37 | describe('Post with no comments', function (done) { 38 | beforeEach(function (done) { 39 | Post.create( 40 | { 41 | title: 'my post without comments', 42 | content: 'my post without comments content' 43 | }, 44 | done 45 | ) 46 | }) 47 | 48 | describe('GET /posts/1/comments', function () { 49 | it('should return status code 200 OK', function (done) { 50 | request(app).get('/posts/1/comments').expect(200).end(done) 51 | }) 52 | 53 | it('should display an empty array keyed by `data`', function (done) { 54 | request(app).get('/posts/1/comments').end(function (err, res) { 55 | expect(err).to.equal(null) 56 | expect(res.body).to.be.an('object') 57 | expect(res.body.links).to.be.an('object') 58 | expect(res.body.links.self).to.match(/posts\/1\/comments/) 59 | expect(res.body.data).to.be.an('array') 60 | expect(res.body.data.length).to.equal(0) 61 | done() 62 | }) 63 | }) 64 | }) 65 | 66 | describe('GET /posts/1/relationships/comments', function () { 67 | it('should return status code 200 OK', function (done) { 68 | request(app) 69 | .get('/posts/1/relationships/comments') 70 | .expect(200) 71 | .end(done) 72 | }) 73 | 74 | it('should display an empty array keyed by `data`', function (done) { 75 | request(app) 76 | .get('/posts/1/relationships/comments') 77 | .end(function (err, res) { 78 | expect(err).to.equal(null) 79 | expect(res.body).to.be.an('object') 80 | expect(res.body.links).to.be.an('object') 81 | expect(res.body.links.self).to.match( 82 | /posts\/1\/relationships\/comments/ 83 | ) 84 | expect(res.body.links.related).to.match(/posts\/1\/comments/) 85 | expect(res.body.data).to.be.an('array') 86 | expect(res.body.data.length).to.equal(0) 87 | done() 88 | }) 89 | }) 90 | }) 91 | }) 92 | 93 | describe('Post with a comment', function () { 94 | beforeEach(function (done) { 95 | Comment.create( 96 | { 97 | title: 'My comment', 98 | comment: 'My comment text' 99 | }, 100 | function (err, comment) { 101 | expect(err).to.equal(null) 102 | expect(comment).to.be.an('object') 103 | Post.create( 104 | { 105 | title: 'my post', 106 | content: 'my post content', 107 | commentIDs: [comment.id] 108 | }, 109 | function (err, post) { 110 | expect(err).to.equal(null) 111 | expect(post).to.be.an('object') 112 | done() 113 | } 114 | ) 115 | } 116 | ) 117 | }) 118 | 119 | describe('GET /posts/1/comments', function () { 120 | it('should return status code 200 OK', function (done) { 121 | request(app).get('/posts/1/comments').expect(200).end(done) 122 | }) 123 | 124 | it('should display a single item array keyed by `data`', function (done) { 125 | request(app).get('/posts/1/comments').end(function (err, res) { 126 | expect(err).to.equal(null) 127 | expect(res.body).to.be.an('object') 128 | expect(res.body.links).to.be.an('object') 129 | expect(res.body.links.self).to.match(/posts\/1\/comments/) 130 | expect(res.body.data).to.be.an('array') 131 | expect(res.body.data.length).to.equal(1) 132 | expect(res.body.data[0].type).to.equal('comments') 133 | done() 134 | }) 135 | }) 136 | }) 137 | 138 | describe('GET /posts/1/relationships/comments', function () { 139 | it('should return status code 200 OK', function (done) { 140 | request(app) 141 | .get('/posts/1/relationships/comments') 142 | .expect(200) 143 | .end(done) 144 | }) 145 | 146 | it('should display a single item array keyed by `data`', function (done) { 147 | request(app) 148 | .get('/posts/1/relationships/comments') 149 | .end(function (err, res) { 150 | expect(err).to.equal(null) 151 | expect(res.body).to.be.an('object') 152 | expect(res.body.links).to.be.an('object') 153 | expect(res.body.links.self).to.match( 154 | /posts\/1\/relationships\/comments/ 155 | ) 156 | expect(res.body.links.related).to.match(/posts\/1\/comments/) 157 | expect(res.body.data).to.be.an('array') 158 | expect(res.body.data.length).to.equal(1) 159 | expect(res.body.data[0]).to.deep.equal({ 160 | type: 'comments', 161 | id: '1' 162 | }) 163 | done() 164 | }) 165 | }) 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /test/reflexiveRelationship.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global describe, beforeEach, it */ 4 | 5 | var request = require('supertest') 6 | var loopback = require('loopback') 7 | var expect = require('chai').expect 8 | var JSONAPIComponent = require('../') 9 | 10 | var app, Folder 11 | 12 | describe('reflexive relationship', function () { 13 | beforeEach(function (done) { 14 | app = loopback() 15 | app.set('legacyExplorer', false) 16 | var ds = loopback.createDataSource('memory') 17 | // create models 18 | Folder = ds.createModel('folder', { 19 | id: { type: Number, id: true }, 20 | name: String 21 | }) 22 | 23 | Folder.hasMany(Folder, { as: 'children', foreignKey: 'parentId' }) 24 | Folder.belongsTo(Folder, { as: 'parent', foreignKey: 'parentId' }) 25 | // add models 26 | app.model(Folder) 27 | 28 | // make data 29 | Folder.create( 30 | [ 31 | { name: 'Folder 1', parentId: 0 }, 32 | { name: 'Folder 2', parentId: 1 }, 33 | { name: 'Folder 3', parentId: 1 }, 34 | { name: 'Folder 4', parentId: 3 } 35 | ], 36 | function (err, foders) { 37 | if (err) console.error(err) 38 | done() 39 | } 40 | ) 41 | 42 | app.use(loopback.rest()) 43 | JSONAPIComponent(app, { restApiRoot: '' }) 44 | }) 45 | 46 | it('should make initial data', function (done) { 47 | request(app).get('/folders').end(function (err, res) { 48 | expect(err).to.equal(null) 49 | expect(res.body.data.length).to.equal(4) 50 | done(err) 51 | }) 52 | }) 53 | 54 | it('should have children', function (done) { 55 | request(app).get('/folders/1/children').end(function (err, res) { 56 | expect(err).to.equal(null) 57 | expect(res.body.data.length).to.equal(2) 58 | expect(res.body.data[0].relationships.children.data).to.equal(undefined) 59 | expect(res.body.data[1].relationships.children.data).to.equal(undefined) 60 | done(err) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/relationship-utils.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const request = require('supertest') 4 | const loopback = require('loopback') 5 | const expect = require('chai').expect 6 | const JSONAPIComponent = require('../') 7 | const utils = require('../lib/utilities/relationship-utils') 8 | const updateHasMany = utils.updateHasMany 9 | const updateHasOne = utils.updateHasOne 10 | const updateBelongsTo = utils.updateBelongsTo 11 | const updateHasManyThrough = utils.updateHasManyThrough 12 | const detectUpdateStrategy = utils.detectUpdateStrategy 13 | const linkRelatedModels = utils.linkRelatedModels 14 | 15 | let app, ds, Post, Comment, PostComment 16 | 17 | function startApp () { 18 | app = loopback() 19 | app.set('legacyExplorer', false) 20 | ds = loopback.createDataSource('memory') 21 | app.use(loopback.rest()) 22 | JSONAPIComponent(app) 23 | } 24 | 25 | function setupHasMany () { 26 | Post = ds.createModel('post', { title: String }) 27 | app.model(Post) 28 | Comment = ds.createModel('comment', { title: String }) 29 | app.model(Comment) 30 | Post.hasMany(Comment) 31 | } 32 | 33 | function setupHasOne () { 34 | Post = ds.createModel('post', { title: String }) 35 | app.model(Post) 36 | Comment = ds.createModel('comment', { title: String }) 37 | app.model(Comment) 38 | Post.hasOne(Comment) 39 | } 40 | 41 | function setupBelongsTo () { 42 | Post = ds.createModel('post', { title: String }) 43 | app.model(Post) 44 | Comment = ds.createModel('comment', { title: String }) 45 | app.model(Comment) 46 | Comment.belongsTo(Post) 47 | } 48 | 49 | function setupHasManyThrough () { 50 | Post = ds.createModel('post', { title: String }) 51 | app.model(Post) 52 | PostComment = ds.createModel('postComment', { title: String }) 53 | app.model(PostComment) 54 | Comment = ds.createModel('comment', { title: String }) 55 | app.model(Comment) 56 | Post.hasMany(Comment, { through: PostComment }) 57 | Comment.hasMany(Post, { through: PostComment }) 58 | PostComment.belongsTo(Post) 59 | PostComment.belongsTo(Comment) 60 | } 61 | 62 | describe('relationship utils', () => { 63 | beforeEach(() => { 64 | startApp() 65 | }) 66 | 67 | describe('Ensure correct hasMany model linkages updated', function () { 68 | it('should update model linkages', () => { 69 | setupHasMany() 70 | const payload = { 71 | data: { 72 | type: 'posts', 73 | attributes: { title: 'my post', content: 'my post content' }, 74 | relationships: { 75 | comments: { 76 | data: [ 77 | { type: 'comments', id: 2 }, 78 | { type: 'comments', id: 3 }, 79 | { type: 'comments', id: 4 } 80 | ] 81 | } 82 | } 83 | } 84 | } 85 | return Comment.create([{ postId: 1 }, { postId: 1 }, { postId: 1 }, {}]) 86 | .then(() => { 87 | return request(app) 88 | .post('/posts') 89 | .send(payload) 90 | .set('Accept', 'application/vnd.api+json') 91 | .set('Content-Type', 'application/json') 92 | .then(res => Comment.find({ where: { postId: res.body.data.id } })) 93 | .then(comments => { 94 | expect(comments.length).to.equal(3) 95 | expect(comments[0].id).to.equal(2) 96 | expect(comments[1].id).to.equal(3) 97 | expect(comments[2].id).to.equal(4) 98 | }) 99 | }) 100 | }) 101 | }) 102 | 103 | describe('updateHasMany()', function () { 104 | it('should update model linkages', () => { 105 | setupHasMany() 106 | return Comment.create([{ postId: 1 }, { postId: 1 }, { postId: 1 }, {}]) 107 | .then(() => updateHasMany('id', 1, Comment, 'postId', [2, 3, 4])) 108 | .then(() => Comment.find({ where: { postId: 1 } })) 109 | .then(comments => { 110 | expect(comments.length).to.equal(3) 111 | expect(comments[0].id).to.equal(2) 112 | expect(comments[1].id).to.equal(3) 113 | expect(comments[2].id).to.equal(4) 114 | }) 115 | }) 116 | }) 117 | 118 | describe('updateHasOne()', function () { 119 | it('should update model linkages', () => { 120 | setupHasOne() 121 | return Comment.create([{}, {}]) 122 | .then(() => updateHasOne('id', 1, Comment, 'postId', 1)) 123 | .then(() => Comment.find({ where: { postId: 1 } })) 124 | .then(comments => { 125 | expect(comments.length).to.equal(1) 126 | expect(comments[0].id).to.equal(1) 127 | }) 128 | }) 129 | }) 130 | 131 | describe('updateBelongsTo()', function () { 132 | it('should update model linkages', () => { 133 | setupBelongsTo() 134 | return Comment.create({}, {}) 135 | .then(() => updateBelongsTo(Comment, 'id', 1, 'postId', 1)) 136 | .then(() => Comment.find({ where: { postId: 1 } })) 137 | .then(comments => { 138 | expect(comments.length).to.equal(1) 139 | expect(comments[0].id).to.equal(1) 140 | }) 141 | }) 142 | }) 143 | 144 | describe('updateHasManyThrough()', function () { 145 | it('should update model linkages', () => { 146 | setupHasManyThrough() 147 | return Promise.all([ 148 | Post.create({}), 149 | Comment.create([{}, {}, {}, {}]), 150 | PostComment.create([ 151 | { postId: 1, commentId: 1 }, 152 | { postId: 1, commentId: 2 }, 153 | { postId: 1, commentId: 3 } 154 | ]) 155 | ]) 156 | .then( 157 | () => 158 | updateHasManyThrough( 159 | 'id', 160 | 1, 161 | PostComment, 162 | 'postId', 163 | 'commentId', 164 | 'id', 165 | [2, 3, 4] 166 | ) 167 | ) 168 | .then(() => PostComment.find({ where: { postId: 1 } })) 169 | .then(postComments => { 170 | expect(postComments.length).to.equal(3) 171 | expect(postComments[0].id).to.equal(2) 172 | expect(postComments[1].id).to.equal(3) 173 | expect(postComments[2].id).to.equal(4) 174 | }) 175 | }) 176 | }) 177 | 178 | describe('linkRelatedModels()', function () { 179 | it('belongsTo', () => { 180 | setupBelongsTo() 181 | const from = { model: Comment, id: 1 } 182 | const to = { model: Post, data: 1 } 183 | return Comment.create({}, {}) 184 | .then(() => linkRelatedModels('post', from, to)) 185 | .then(() => Comment.find({ where: { postId: 1 } })) 186 | .then(comments => { 187 | expect(comments.length).to.equal(1) 188 | expect(comments[0].id).to.equal(1) 189 | }) 190 | }) 191 | it('hasMany', () => { 192 | setupHasMany() 193 | const from = { model: Post, id: 1 } 194 | const to = { model: Comment, data: [2, 3, 4] } 195 | return Comment.create([{ postId: 1 }, { postId: 1 }, { postId: 1 }, {}]) 196 | .then(() => linkRelatedModels('comments', from, to)) 197 | .then(() => Comment.find({ where: { postId: 1 } })) 198 | .then(comments => { 199 | expect(comments.length).to.equal(3) 200 | expect(comments[0].id).to.equal(2) 201 | expect(comments[1].id).to.equal(3) 202 | expect(comments[2].id).to.equal(4) 203 | }) 204 | }) 205 | it('hasManyThrough', () => { 206 | setupHasManyThrough() 207 | const from = { model: Post, id: 1 } 208 | const to = { model: Comment, data: [2, 3, 4] } 209 | return Promise.all([ 210 | Post.create({}), 211 | Comment.create([{}, {}, {}, {}]), 212 | PostComment.create([ 213 | { postId: 1, commentId: 1 }, 214 | { postId: 1, commentId: 2 }, 215 | { postId: 1, commentId: 3 } 216 | ]) 217 | ]) 218 | .then(() => linkRelatedModels('comments', from, to)) 219 | .then(() => PostComment.find({ where: { postId: 1 } })) 220 | .then(postComments => { 221 | expect(postComments.length).to.equal(3) 222 | expect(postComments[0].id).to.equal(2) 223 | expect(postComments[1].id).to.equal(3) 224 | expect(postComments[2].id).to.equal(4) 225 | }) 226 | }) 227 | it('hasOne', () => { 228 | setupHasOne() 229 | const from = { model: Post, id: 1 } 230 | const to = { model: Comment, data: 1 } 231 | return Comment.create([{}, {}]) 232 | .then(() => linkRelatedModels('comment', from, to)) 233 | .then(() => Comment.find({ where: { postId: 1 } })) 234 | .then(comments => { 235 | expect(comments.length).to.equal(1) 236 | expect(comments[0].id).to.equal(1) 237 | }) 238 | }) 239 | it('hasOne null', () => { 240 | setupHasOne() 241 | const from = { model: Post, id: 1 } 242 | const to = { model: Comment, data: null } 243 | return Comment.create([{ postId: 1 }]) 244 | .then(() => linkRelatedModels('comment', from, to)) 245 | .then(() => Comment.find({ where: { postId: null } })) 246 | .then(comments => { 247 | expect(comments.length).to.equal(1) 248 | expect(comments[0].id).to.equal(1) 249 | }) 250 | }) 251 | }) 252 | 253 | describe('detectUpdateStrategy()', function () { 254 | it('hasManyThrough', () => { 255 | setupHasManyThrough() 256 | expect(detectUpdateStrategy(Post, 'comments')).to.equal(0) 257 | }) 258 | it('hasMany', () => { 259 | setupHasMany() 260 | expect(detectUpdateStrategy(Post, 'comments')).to.equal(1) 261 | }) 262 | it('hasOne', () => { 263 | setupHasOne() 264 | expect(detectUpdateStrategy(Post, 'comment')).to.equal(2) 265 | }) 266 | it('belongsTo', () => { 267 | setupBelongsTo() 268 | expect(detectUpdateStrategy(Comment, 'post')).to.equal(3) 269 | }) 270 | }) 271 | }) 272 | -------------------------------------------------------------------------------- /test/remoteMethods.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app, Post, Archive 8 | 9 | describe('loopback json api remote methods', function () { 10 | var autocompleteTitleData = ['Post 1', 'Post 2'] 11 | 12 | var archiveData = { 13 | id: 10, 14 | raw: { 15 | id: 1, 16 | title: 'Ancient Post 1', 17 | content: 'Ancient Content of Post 1' 18 | }, 19 | createdAt: Date.now() 20 | } 21 | 22 | var postData = { 23 | id: 1, 24 | title: 'Post 1', 25 | content: 'Content of Post 1' 26 | } 27 | 28 | beforeEach(function (done) { 29 | app = loopback() 30 | app.set('legacyExplorer', false) 31 | var ds = loopback.createDataSource('memory') 32 | 33 | Archive = ds.createModel('archive', { 34 | id: { type: Number, id: true }, 35 | raw: Object, 36 | createAt: Date 37 | }) 38 | 39 | Post = ds.createModel('post', { 40 | id: { type: Number, id: true }, 41 | title: String, 42 | content: String 43 | }) 44 | 45 | Post.greet = function (msg, cb) { 46 | cb(null, 'Greetings... ' + msg) 47 | } 48 | 49 | Post.last = function (cb) { 50 | Post.findOne({}, cb) 51 | } 52 | 53 | Post.autocomplete = function (q, cb) { 54 | cb(null, autocompleteTitleData) 55 | } 56 | 57 | Post.prototype.findArchives = function (cb) { 58 | Archive.find({}, cb) 59 | } 60 | 61 | Post.remoteMethod('greet', { 62 | accepts: { arg: 'msg', type: 'string' }, 63 | returns: { arg: 'greeting', type: 'string' } 64 | }) 65 | 66 | Post.remoteMethod('last', { 67 | returns: { root: true }, 68 | http: { verb: 'get' } 69 | }) 70 | 71 | Post.remoteMethod('autocomplete', { 72 | jsonapi: false, 73 | accepts: { arg: 'q', type: 'string', http: { source: 'query' } }, 74 | returns: { root: true, type: 'array' }, 75 | http: { path: '/autocomplete', verb: 'get' } 76 | }) 77 | 78 | Post.remoteMethod('prototype.findArchives', { 79 | jsonapi: true, 80 | returns: { root: true, type: 'archive' }, 81 | http: { path: '/archives' } 82 | }) 83 | 84 | app.model(Post) 85 | app.model(Archive) 86 | app.use(loopback.rest()) 87 | 88 | Promise.all([Archive.create(archiveData), Post.create(postData)]) 89 | .then(function () { 90 | done() 91 | }) 92 | }) 93 | 94 | describe('for application/json', function () { 95 | beforeEach(function () { 96 | JSONAPIComponent(app) 97 | }) 98 | 99 | it('POST /posts/greet should return remote method message', function (done) { 100 | request(app) 101 | .post('/posts/greet') 102 | .send({ msg: 'John' }) 103 | .set('Content-Type', 'application/json') 104 | .expect(200) 105 | .end(function (err, res) { 106 | expect(err).to.equal(null) 107 | expect(res.body.greeting).to.equal('Greetings... John') 108 | done() 109 | }) 110 | }) 111 | }) 112 | 113 | describe('should serialize with `jsonapi: true`', function () { 114 | beforeEach(function () { 115 | JSONAPIComponent(app) 116 | }) 117 | 118 | testAutocompleteJsonAPI() 119 | testArchivesJsonAPI() 120 | }) 121 | 122 | describe('when `serializeCustomRemote` is set', function (done) { 123 | beforeEach(function () { 124 | JSONAPIComponent(app, { handleCustomRemoteMethods: true }) 125 | }) 126 | 127 | testAutocompleteJsonAPI() 128 | testArchivesJsonAPI() 129 | testLastJsonAPI() 130 | }) 131 | 132 | describe('when `exclude` is set', function (done) { 133 | beforeEach(function () { 134 | JSONAPIComponent(app, { 135 | handleCustomRemoteMethods: true, 136 | exclude: [{ methods: ['last', 'autocomplete', 'archives'] }], 137 | include: [{ methods: 'last' }] 138 | }) 139 | }) 140 | 141 | testArchivesJsonAPI() 142 | testAutocompleteJsonAPI() 143 | testLastRaw() 144 | }) 145 | 146 | describe('when `include` is set', function (done) { 147 | beforeEach(function () { 148 | JSONAPIComponent(app, { 149 | handleCustomRemoteMethods: false, 150 | include: [{ methods: 'last' }] 151 | }) 152 | }) 153 | 154 | testArchivesJsonAPI() 155 | testAutocompleteJsonAPI() 156 | testLastJsonAPI() 157 | }) 158 | 159 | /* Static test */ 160 | function testAutocompleteJsonAPI () { 161 | it( 162 | 'GET /posts/autocomplete should return an array with raw format (`jsonapi: false` has precedence)', 163 | function (done) { 164 | request(app) 165 | .get('/posts/autocomplete') 166 | .expect(200) 167 | .end(function (err, res) { 168 | expect(err).to.equal(null) 169 | expect(res.body).to.deep.equal(autocompleteTitleData) 170 | done() 171 | }) 172 | } 173 | ) 174 | } 175 | 176 | /* Static test */ 177 | function testArchivesJsonAPI () { 178 | it( 179 | 'GET /posts/1/archives should return a JSONApi list of Archive (`jsonapi: true` has precedence)', 180 | function (done) { 181 | request(app) 182 | .get('/posts/1/archives') 183 | .expect(200) 184 | .end(function (err, res) { 185 | expect(err).to.equal(null) 186 | expect(res.body).to.be.an('object') 187 | expect(res.body.data).to.be.an('array').with.lengthOf(1) 188 | expect(res.body.data[0].type).to.equal('archives') 189 | expect(res.body.data[0].id).to.equal(archiveData.id + '') 190 | expect(res.body.data[0].attributes).to.deep.equal({ 191 | raw: archiveData.raw, 192 | createdAt: archiveData.createdAt 193 | }) 194 | 195 | done() 196 | }) 197 | } 198 | ) 199 | } 200 | 201 | /* Static test */ 202 | function testLastJsonAPI () { 203 | it('GET /posts/last should return a JSONApi Post', function (done) { 204 | request(app).get('/posts/last').expect(200).end(function (err, res) { 205 | expect(err).to.equal(null) 206 | 207 | expect(res.body.data).to.be.an('object') 208 | expect(res.body.data.id).to.equal(postData.id + '') 209 | expect(res.body.data.type).to.equal('posts') 210 | expect(res.body.data.attributes).to.deep.equal({ 211 | title: postData.title, 212 | content: postData.content 213 | }) 214 | 215 | done() 216 | }) 217 | }) 218 | } 219 | 220 | /* Static test */ 221 | function testLastRaw () { 222 | it( 223 | 'GET /posts/last should return a raw Post (exclude is more important than include and handleCustomRemoteMethods)', 224 | function (done) { 225 | request(app).get('/posts/last').expect(200).end(function (err, res) { 226 | expect(err).to.equal(null) 227 | expect(res.body).to.deep.equal(postData) 228 | done() 229 | }) 230 | } 231 | ) 232 | } 233 | }) 234 | -------------------------------------------------------------------------------- /test/rule-hasmany-actions.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Rule 9 | var Action 10 | var ds 11 | 12 | describe('Rule `hasMany` Action relationships', function () { 13 | beforeEach(function () { 14 | app = loopback() 15 | app.set('legacyExplorer', false) 16 | ds = loopback.createDataSource('memory') 17 | 18 | Action = ds.createModel('action', { 19 | name: String 20 | }) 21 | Action.settings.plural = 'actions' 22 | app.model(Action) 23 | 24 | Rule = ds.createModel('rule', { 25 | name: String 26 | }) 27 | Rule.settings.plural = 'rules' 28 | app.model(Rule) 29 | 30 | Rule.hasMany(Action, { as: 'actions', foreignKey: 'ruleId' }) 31 | app.use(loopback.rest()) 32 | JSONAPIComponent(app) 33 | }) 34 | 35 | describe('updating relationships using a PATCH', function (done) { 36 | beforeEach(function (done) { 37 | Action.create( 38 | [ 39 | { name: 'action 1', ruleId: 10 }, 40 | { name: 'action 2', ruleId: 10 }, 41 | { name: 'action 3', ruleId: 10 } 42 | ], 43 | function (err, actions) { 44 | if (err) console.error(err) 45 | Rule.create([{ name: 'rule 1' }, { name: 'rule 2' }], done) 46 | } 47 | ) 48 | }) 49 | it('should update foreign keys on Action model', function () { 50 | request(app) 51 | .patch('/rules/2') 52 | .send({ 53 | data: { 54 | id: 2, 55 | attributes: { 56 | name: 'enter 2' 57 | }, 58 | relationships: { 59 | actions: { 60 | data: [{ type: 'actions', id: 2 }, { type: 'actions', id: 3 }] 61 | } 62 | }, 63 | type: 'rules' 64 | } 65 | }) 66 | .set('Accept', 'application/vnd.api+json') 67 | .set('Content-Type', 'application/json') 68 | .end(function (err, res) { 69 | expect(err).to.equal(null) 70 | expect(res.status).to.equal(200) 71 | expect(res.body).not.to.have.keys('errors') 72 | Rule.find(function (err, rules) { 73 | if (err) console.error(err) 74 | Action.find(function (err, actions) { 75 | if (err) console.error(err) 76 | expect(actions[0].ruleId).to.equal(10) 77 | expect(actions[1].ruleId).to.equal(2) 78 | expect(actions[2].ruleId).to.equal(2) 79 | done() 80 | }) 81 | }) 82 | }) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/scopeInclude.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var ds, app, Post, Author, Comment, Category 8 | 9 | describe('include option', function () { 10 | beforeEach(function () { 11 | app = loopback() 12 | app.set('legacyExplorer', false) 13 | ds = loopback.createDataSource('memory') 14 | Post = ds.createModel( 15 | 'post', 16 | { 17 | id: { type: Number, id: true }, 18 | title: String, 19 | content: String 20 | }, 21 | { 22 | scope: { 23 | include: 'comments' 24 | } 25 | } 26 | ) 27 | app.model(Post) 28 | 29 | Comment = ds.createModel('comment', { 30 | id: { type: Number, id: true }, 31 | postId: Number, 32 | authorId: Number, 33 | title: String, 34 | comment: String 35 | }) 36 | 37 | app.model(Comment) 38 | 39 | Author = ds.createModel('author', { 40 | id: { type: Number, id: true }, 41 | name: String 42 | }) 43 | 44 | app.model(Author) 45 | 46 | Category = ds.createModel('category', { 47 | id: { type: Number, id: true }, 48 | name: String 49 | }) 50 | 51 | app.model(Author) 52 | 53 | Post.hasMany(Comment, { as: 'comments', foreignKey: 'postId' }) 54 | Post.belongsTo(Author, { as: 'author', foreignKey: 'authorId' }) 55 | Post.belongsTo(Category, { as: 'category', foreignKey: 'categoryId' }) 56 | Comment.settings.plural = 'comments' 57 | 58 | app.use(loopback.rest()) 59 | JSONAPIComponent(app, { restApiRoot: '/' }) 60 | }) 61 | 62 | describe('include defined at model level', function () { 63 | beforeEach(function (done) { 64 | Post.create( 65 | { 66 | title: 'my post', 67 | content: 'my post content' 68 | }, 69 | function (err, post) { 70 | expect(err).to.equal(null) 71 | post.comments.create( 72 | { 73 | title: 'My comment', 74 | comment: 'My comment text' 75 | }, 76 | function () { 77 | post.comments.create( 78 | { 79 | title: 'My second comment', 80 | comment: 'My second comment text' 81 | }, 82 | function () { 83 | post.author.create( 84 | { 85 | name: 'Joe' 86 | }, 87 | function () { 88 | post.category.create( 89 | { 90 | name: 'Programming' 91 | }, 92 | done 93 | ) 94 | } 95 | ) 96 | } 97 | ) 98 | } 99 | ) 100 | } 101 | ) 102 | }) 103 | 104 | describe('response', function () { 105 | it('should have key `included`', function (done) { 106 | request(app).get('/posts/1').end(function (err, res) { 107 | expect(err).to.equal(null) 108 | expect(res.body.included).to.be.an('array') 109 | done() 110 | }) 111 | }) 112 | 113 | it('attributes should not have relationship key', function (done) { 114 | request(app).get('/posts/1').end(function (err, res) { 115 | expect(err).to.equal(null) 116 | expect(res.body.data.attributes).to.not.include.key('comments') 117 | done() 118 | }) 119 | }) 120 | 121 | it('with include paramter should have both models', function (done) { 122 | request(app) 123 | .get('/posts/1?filter[include]=author') 124 | .end(function (err, res) { 125 | expect(err).to.equal(null) 126 | expect(res.body.included.length).equal(3) 127 | expect(res.body.included[0].type).equal('authors') 128 | expect(res.body.included[1].type).equal('comments') 129 | done() 130 | }) 131 | }) 132 | it('should include comments', function (done) { 133 | request(app) 134 | .get('/posts/1?filter={"include":["comments"]}') 135 | .end(function (err, res) { 136 | expect(err).to.equal(null) 137 | expect(res.body.included.length).equal(2) 138 | expect(res.body.included[0].type).equal('comments') 139 | expect(res.body.included[1].type).equal('comments') 140 | done() 141 | }) 142 | }) 143 | it('should include categories with empty attributes object', function ( 144 | done 145 | ) { 146 | request(app) 147 | .get( 148 | '/posts/1?filter={"include":[{"relation":"category", "scope": {"fields": ["id"]}}]}' 149 | ) 150 | .end(function (err, res) { 151 | expect(err).to.equal(null) 152 | expect(res.body.included.length).equal(3) 153 | expect(res.body.included[0].type).equal('categories') 154 | expect(res.body.included[0].attributes).to.include({}) 155 | done() 156 | }) 157 | }) 158 | }) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /test/throughModel.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var RSVP = require('rsvp') 8 | var app, User, Interest, Topic 9 | 10 | describe('through Model', function () { 11 | beforeEach(function () { 12 | app = loopback() 13 | app.set('legacyExplorer', false) 14 | var ds = loopback.createDataSource('memory') 15 | 16 | User = ds.createModel('user', { 17 | id: { type: Number, id: true }, 18 | name: String 19 | }) 20 | 21 | app.model(User) 22 | 23 | Topic = ds.createModel('topic', { 24 | id: { type: Number, id: true }, 25 | name: String 26 | }) 27 | 28 | app.model(Topic) 29 | 30 | Interest = ds.createModel('interest', { 31 | id: { type: Number, id: true } 32 | }) 33 | 34 | app.model(Interest) 35 | 36 | User.hasMany(Topic, { through: Interest }) 37 | Topic.hasMany(User, { through: Interest }) 38 | 39 | Interest.belongsTo(User) 40 | Interest.belongsTo(Topic) 41 | 42 | app.use(loopback.rest()) 43 | JSONAPIComponent(app, { restApiRoot: '/' }) 44 | }) 45 | 46 | it('should allow interest to be created', function (done) { 47 | User.create({ name: 'User 1' }, function (err, user) { 48 | expect(err).to.equal(null) 49 | 50 | Topic.create({ name: 'Topic 1' }, function (err, topic) { 51 | expect(err).to.equal(null) 52 | 53 | request(app) 54 | .post('/interests') 55 | .send({ 56 | data: { 57 | type: 'interests', 58 | attributes: {}, 59 | relationships: { 60 | user: { data: { id: user.id, type: 'users' } }, 61 | topic: { data: { id: topic.id, type: 'topics' } } 62 | } 63 | } 64 | }) 65 | .end(function (err, res) { 66 | expect(err).to.equal(null) 67 | expect(res).to.not.have.deep.property('body.errors') 68 | expect(res.body.data.id).to.equal('1') 69 | done(err) 70 | }) 71 | }) 72 | }) 73 | }) 74 | 75 | it('should retrieve user topics via include', function (done) { 76 | makeData() 77 | .then(function () { 78 | request(app).get('/users/1?include=topics').end(function (err, res) { 79 | expect(err).to.equal(null) 80 | expect(res.body.data.relationships.topics.data).to.have 81 | .property('length') 82 | .and.equal(2) 83 | expect(res.body.data.relationships.topics.data[0].type).to.equal( 84 | 'topics' 85 | ) 86 | expect(res.body.data.relationships.topics.data[1].type).to.equal( 87 | 'topics' 88 | ) 89 | expect(res.body.included[0].type).to.equal('topics') 90 | expect(res.body.included[0].id).to.equal('1') 91 | expect(res.body.included[1].type).to.equal('topics') 92 | expect(res.body.included[1].id).to.equal('2') 93 | done(err) 94 | }) 95 | }) 96 | .catch(done) 97 | }) 98 | 99 | it('should retrieve topic users via include', function (done) { 100 | makeData() 101 | .then(function () { 102 | request(app).get('/topics/1?include=users').end(function (err, res) { 103 | expect(err).to.equal(null) 104 | expect(res.body.data.relationships.users.data).to.have 105 | .property('length') 106 | .and.equal(1) 107 | expect(res.body.data.relationships.users.data[0].type).to.equal( 108 | 'users' 109 | ) 110 | expect(res.body.included[0].type).to.equal('users') 111 | expect(res.body.included[0].id).to.equal('1') 112 | done(err) 113 | }) 114 | }) 115 | .catch(done) 116 | }) 117 | }) 118 | 119 | function makeData (done) { 120 | var createUser = denodeifyCreate(User) 121 | var createTopic = denodeifyCreate(Topic) 122 | var createInterest = denodeifyCreate(Interest) 123 | 124 | return RSVP.hash({ 125 | user: createUser({ name: 'User 1' }), 126 | topics: RSVP.all([ 127 | createTopic({ name: 'Topic 1' }), 128 | createTopic({ name: 'Topic 2' }) 129 | ]) 130 | }) 131 | .then(function (models) { 132 | return RSVP.all([ 133 | createInterest({ 134 | userId: models.user.id, 135 | topicId: models.topics[0].id 136 | }), 137 | createInterest({ userId: models.user.id, topicId: models.topics[1].id }) 138 | ]) 139 | }) 140 | 141 | function denodeifyCreate (Model) { 142 | return RSVP.denodeify(Model.create.bind(Model)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/update.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('supertest') 4 | var loopback = require('loopback') 5 | var expect = require('chai').expect 6 | var JSONAPIComponent = require('../') 7 | var app 8 | var Post 9 | 10 | describe('loopback json api component update method', function () { 11 | beforeEach(function (done) { 12 | app = loopback() 13 | app.set('legacyExplorer', false) 14 | var ds = loopback.createDataSource('memory') 15 | Post = ds.createModel('post', { 16 | id: { 17 | type: Number, 18 | id: true 19 | }, 20 | title: String, 21 | content: String 22 | }) 23 | app.model(Post) 24 | app.use(loopback.rest()) 25 | JSONAPIComponent(app) 26 | Post.create( 27 | { 28 | title: 'my post', 29 | content: 'my post content' 30 | }, 31 | done 32 | ) 33 | }) 34 | 35 | describe('Headers', function () { 36 | it( 37 | 'PATCH /models/:id should have the JSON API Content-Type header set on response', 38 | function (done) { 39 | // TODO: superagent/supertest breaks when trying to use JSON API Content-Type header 40 | // waiting on a fix 41 | // see https://github.com/visionmedia/superagent/issues/753 42 | // using Content-Type: application/json in the mean time. 43 | // Have tested correct header using curl and all is well 44 | // request(app).patch('/posts/1') 45 | // .send({ 46 | // data: { 47 | // type: 'posts', 48 | // attributes: { 49 | // title: 'my post', 50 | // content: 'my post content' 51 | // } 52 | // } 53 | // }) 54 | // .set('Content-Type', 'application/vnd.api+json') 55 | // .expect('Content-Type', 'application/vnd.api+json; charset=utf-8') 56 | // .end(done) 57 | done() 58 | } 59 | ) 60 | }) 61 | 62 | describe('Status codes', function () { 63 | it('PATCH /models/:id should return a 200 status code', function (done) { 64 | request(app) 65 | .patch('/posts/1') 66 | .send({ 67 | data: { 68 | type: 'posts', 69 | id: 1, 70 | attributes: { 71 | title: 'my post title changed', 72 | content: 'my post content changed' 73 | } 74 | } 75 | }) 76 | .set('Content-Type', 'application/json') 77 | .expect(200) 78 | .end(done) 79 | }) 80 | 81 | it( 82 | 'PATCH /models/:id should return 404 when attempting to edit non existing resource', 83 | function (done) { 84 | request(app) 85 | .patch('/posts/10') 86 | .send({ 87 | data: { 88 | type: 'posts', 89 | id: 10, 90 | attributes: { 91 | title: 'my post title changed', 92 | content: 'my post content changed' 93 | } 94 | } 95 | }) 96 | .expect(404) 97 | .end(done) 98 | } 99 | ) 100 | }) 101 | 102 | describe('Links', function () { 103 | it('should produce resource level self links', function (done) { 104 | request(app) 105 | .patch('/posts/1') 106 | .send({ 107 | data: { 108 | type: 'posts', 109 | id: 1, 110 | attributes: { 111 | title: 'my post title changed', 112 | content: 'my post content changed' 113 | } 114 | } 115 | }) 116 | .set('Content-Type', 'application/json') 117 | .end(function (err, res) { 118 | expect(err).to.equal(null) 119 | expect(res.body.data.links.self).to.match( 120 | /http:\/\/127\.0\.0\.1.*\/posts\/1/ 121 | ) 122 | done() 123 | }) 124 | }) 125 | }) 126 | 127 | describe('Updating a resource using PATCH', function () { 128 | it('PATCH /models/:id should return a correct JSON API response', function ( 129 | done 130 | ) { 131 | request(app) 132 | .patch('/posts/1') 133 | .send({ 134 | data: { 135 | type: 'posts', 136 | id: 1, 137 | attributes: { 138 | title: 'my post title changed', 139 | content: 'my post content changed' 140 | } 141 | } 142 | }) 143 | .set('Content-Type', 'application/json') 144 | .end(function (err, res) { 145 | expect(err).to.equal(null) 146 | expect(res.body.data.id).to.equal('1') 147 | expect(res.body.data.type).to.equal('posts') 148 | expect(res.body.data.attributes.title).to.equal( 149 | 'my post title changed' 150 | ) 151 | expect(res.body.data.attributes.content).to.equal( 152 | 'my post content changed' 153 | ) 154 | done() 155 | }) 156 | }) 157 | 158 | it( 159 | "PATCH /models/:id if property in `attributes` is not present, it should not be considered null or change from it's original value", 160 | function (done) { 161 | request(app) 162 | .patch('/posts/1') 163 | .send({ 164 | data: { 165 | type: 'posts', 166 | id: 1, 167 | attributes: { 168 | content: 'only changing content, not title' 169 | } 170 | } 171 | }) 172 | .set('Content-Type', 'application/json') 173 | .end(function (err, res) { 174 | expect(err).to.equal(null) 175 | expect(res.body.data.id).to.equal('1') 176 | expect(res.body.data.type).to.equal('posts') 177 | expect(res.body.data.attributes.title).to.equal('my post') 178 | expect(res.body.data.attributes.content).to.equal( 179 | 'only changing content, not title' 180 | ) 181 | done() 182 | }) 183 | } 184 | ) 185 | }) 186 | 187 | describe('Errors', function () { 188 | it( 189 | 'PATCH /models/:id with empty `attributes` should not return an error and should not modify existing attributes', 190 | function (done) { 191 | request(app) 192 | .patch('/posts/1') 193 | .send({ data: { type: 'posts', id: 1, attributes: {} } }) 194 | .set('Content-Type', 'application/json') 195 | .expect(200) 196 | .end(function (err, res) { 197 | expect(err).to.equal(null) 198 | expect(res.body.data.id).to.equal('1') 199 | expect(res.body.data.type).to.equal('posts') 200 | expect(res.body.data.attributes.title).to.equal('my post') 201 | expect(res.body.data.attributes.content).to.equal( 202 | 'my post content' 203 | ) 204 | done() 205 | }) 206 | } 207 | ) 208 | 209 | it( 210 | 'PATCH /models/:id with no `attributes` should not return an error and should not modify existing attributes', 211 | function (done) { 212 | request(app) 213 | .patch('/posts/1') 214 | .send({ 215 | data: { 216 | type: 'posts', 217 | id: 1 218 | } 219 | }) 220 | .set('Content-Type', 'application/json') 221 | .expect(200) 222 | .end(function (err, res) { 223 | expect(err).to.equal(null) 224 | expect(res.body.data.id).to.equal('1') 225 | expect(res.body.data.type).to.equal('posts') 226 | expect(res.body.data.attributes.title).to.equal('my post') 227 | expect(res.body.data.attributes.content).to.equal( 228 | 'my post content' 229 | ) 230 | done() 231 | }) 232 | } 233 | ) 234 | 235 | it( 236 | 'PATCH /models/:id should return an 422 error if `type` key is not present and include an errors array', 237 | function (done) { 238 | request(app) 239 | .patch('/posts/1') 240 | .send({ 241 | data: { 242 | id: 1, 243 | attributes: { 244 | title: 'my post', 245 | content: 'my post content' 246 | } 247 | } 248 | }) 249 | .expect(422) 250 | .set('Content-Type', 'application/json') 251 | .end(function (err, res) { 252 | expect(err).to.equal(null) 253 | expect(res.body).to.have.keys('errors') 254 | expect(res.body.errors).to.be.a('array') 255 | expect(res.body.errors.length).to.equal(1) 256 | expect(res.body.errors[0].status).to.equal(422) 257 | expect(res.body.errors[0].title).to.equal('ValidationError') 258 | expect(res.body.errors[0].detail).to.equal( 259 | 'JSON API resource object must contain `data.type` property' 260 | ) 261 | done() 262 | }) 263 | } 264 | ) 265 | 266 | it( 267 | 'PATCH /models/:id should return an 422 error if `id` key is not present and include an errors array', 268 | function (done) { 269 | request(app) 270 | .patch('/posts/1') 271 | .send({ 272 | data: { 273 | type: 'posts', 274 | attributes: { 275 | title: 'my post', 276 | content: 'my post content' 277 | } 278 | } 279 | }) 280 | .expect(422) 281 | .set('Content-Type', 'application/json') 282 | .end(function (err, res) { 283 | expect(err).to.equal(null) 284 | expect(res.body).to.have.keys('errors') 285 | expect(res.body.errors).to.be.a('array') 286 | expect(res.body.errors.length).to.equal(1) 287 | expect(res.body.errors[0].status).to.equal(422) 288 | expect(res.body.errors[0].title).to.equal('ValidationError') 289 | expect(res.body.errors[0].detail).to.equal( 290 | 'JSON API resource object must contain `data.id` property' 291 | ) 292 | done() 293 | }) 294 | } 295 | ) 296 | }) 297 | }) 298 | 299 | describe('non standard primary key naming', function () { 300 | beforeEach(function (done) { 301 | app = loopback() 302 | app.set('legacyExplorer', false) 303 | var ds = loopback.createDataSource('memory') 304 | Post = ds.createModel('post', { 305 | customId: { type: Number, id: true, generated: true }, 306 | title: String 307 | }) 308 | app.model(Post) 309 | app.use(loopback.rest()) 310 | JSONAPIComponent(app) 311 | Post.create({ title: 'my post' }, done) 312 | }) 313 | 314 | it('should dynamically handle primary key', function (done) { 315 | request(app) 316 | .patch('/posts/1') 317 | .send({ 318 | data: { 319 | id: 1, 320 | type: 'posts', 321 | attributes: { title: 'my post 2' } 322 | } 323 | }) 324 | .expect(200) 325 | .end(function (err, res) { 326 | expect(err).to.equal(null) 327 | expect(res.body.data.id).to.equal('1') 328 | expect(res.body.data.attributes.title).to.equal('my post 2') 329 | expect(res.body.data.links.self).to.match( 330 | /http:\/\/127\.0\.0\.1.*\/posts\/1/ 331 | ) 332 | done() 333 | }) 334 | }) 335 | }) 336 | -------------------------------------------------------------------------------- /test/util/query.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var http = require('http') 4 | 5 | module.exports = function (app) { 6 | return { 7 | post: function (path, data, options, cb) { 8 | var s = app.listen(function () { 9 | var req = http.request( 10 | { 11 | port: s.address().port, 12 | path: path, 13 | headers: options.headers || {}, 14 | method: 'POST' 15 | }, 16 | function (res) { 17 | res.setEncoding('utf8') 18 | res.rawData = '' 19 | res.on('data', function (chunk) { 20 | res.rawData += chunk 21 | }) 22 | res.on('end', function () { 23 | res.body = JSON.parse(res.rawData) 24 | cb(null, res) 25 | }) 26 | } 27 | ) 28 | req.on('error', function (err) { 29 | cb(err) 30 | }) 31 | var postData = JSON.stringify(data || {}) 32 | req.write(postData) 33 | req.end() 34 | }) 35 | } 36 | } 37 | } 38 | --------------------------------------------------------------------------------